diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index de74e248..1cee3dcc 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -398,250 +398,17 @@
Check the combined result and confirm it
The group has its key — finish setting it up
Open
- Nostr profile
- This group hasn't said anything about itself on Nostr yet.
-
- Not described yet
- Edit Nostr profile
- Propose Nostr profile
- What the group says about itself on Nostr
- The group signs this, so it takes a quorum. An empty field unsays what the group had said.
- This group has no shared key, so it cannot sign a profile. Run a shared key ceremony first.
Signed by the group on %1$s
- No name on this profile
- About
- Picture url
- Website
- Nostr address
- Lightning address
- eg. Translation collective
- eg. A group translating hard books into Sesotho
- eg. https://example.com/group.png
- eg. https://example.com
- eg. group@example.com
- eg. group@getalby.com
- Couldn't ask the group to sign this profile.
Relays
- Edit relays
- Not set yet
- No relays in this list
-
- %1$s of %2$s lists agreed
- Where the group lives on Nostr
- The group signs each list, so it takes a quorum. Only the lists you change are proposed.
- This group has no shared key, so it cannot sign a relay list. Run a shared key ceremony first.
Relay url
eg. wss://relay.example.com
Add relay
That isn't a relay address. Relays start with wss:// and cannot be on this device.
That relay is already in this list.
- Propose relays
- Nothing has changed, so there is nothing to propose.
- Couldn't ask the group to sign these relays.
- Read
- Write
- General
- Messages
- Search
- Blocked
- Where the group publishes, and where a reader should look for it.
- Where a message addressed to the group should be sent.
- The relays a search of the group's work runs against.
- Relays the group will not talk to.
- A group signs and cannot decrypt, so every list here is public — including the blocked one, which most clients keep private.
- Signed %1$s
- Posts
- Add post
- Propose post
- This group hasn't posted anything yet.
-
- No posts yet
- 1 post
- %1$s posts
- What should the group say?
- The group signs a post, so it takes a quorum. It goes out in the group's name, not yours.
- This group has no shared key, so it cannot sign a post. Run a shared key ceremony first.
- A post needs something to say.
- Couldn't ask the group to sign this post.
- Posted %1$s
- A reply
- Curated schemas
- This group hasn't published a curated schema yet. A schema defines a list anyone can suggest entries to and only the group can curate.
-
- No schemas yet
- 1 schema
- %1$s schemas
- Add schema
- Edit schema
- Propose schema
- Fields: %1$s
- Public
- Closed
- Private
- Anyone may suggest an entry, and anyone may read the list.
- Only the group and the suggesters below may suggest. Anyone may read the list.
- Only the group and the suggesters below may suggest, and clients show the list to them alone. Relays are open, so this is a convention rather than a secret.
- What an entry in this list may contain, and who may suggest one
- The group signs a curated schema, so it takes a quorum. Suggestions reply to it, and only the group can accept them into the list.
- This group has no shared key, so it cannot sign a curated schema. Run a shared key ceremony first.
- Couldn't ask the group to sign this schema.
- Identifier
- eg. bitcoin.mov
- The identifier names this list in every reply to it, so it can't change. Add a schema to start another list.
- Title
- eg. bitcoin.mov suggestion
- Description
- eg. Fields for a bitcoin.mov entry: a film, with at least one link to watch it
- Visibility
- Domain
- eg. example.com
- A domain replaces the name wherever the list is shown. It's a claim rather than proof, so only set one the group controls.
- Fields
- Add field
- Required
- Optional
- Derived
- At least one of
- Add rule
- Pick two or more fields, at least one of which an entry must have. A rule with fewer than two is dropped.
- Suggesters
- Pubkeys besides the group that may suggest to this list.
- npub or hex pubkey
- Add suggester
- That isn't a pubkey. Paste an npub or 64 hex characters.
- That pubkey is already a suggester.
- Where suggestions and entries are published to and read from. Signed into the schema, so a reply can't be sent anywhere else.
- No relays named, so a client will pick its own.
- New field
- Edit field
- Field name
- eg. watchUrl
- Label
- eg. Watch / reference url
- Placeholder
- eg. https://youtube.com/watch?v=…
- Type
- An entry must have this field
- Writes to tag
- Defaults to the field name. "content" means the event's body.
- Marker
- eg. watch
- The tag's third element, which tells fields sharing a tag apart.
- Maximum characters
- Maximum value
- Minimum value
- Options
- One per line, or separated by commas.
- https only
- May appear more than once
- Pattern
- A regular expression the whole value must match.
- Filled in by the app, never asked for
- Hint
- Helper text under the input.
- Done
- Remove field
- A field needs a name.
- A field name is one word, with no spaces.
- A field named that already exists.
- An identifier is required.
- The identifier must be at most %1$s characters.
- A title is required.
- The title must be at most %1$s characters.
- A name is required.
- The name must be at most %1$s characters.
- A description is required.
- The description must be at most %1$s characters.
- Say who may suggest to this list.
- The picture must be an https url.
- That isn't a domain name.
- A schema needs at least one field.
- One of the relays isn't a relay address.
- eg. 2100
- eg. 200
- eg. 1900
- eg. movie, documentary, short
- eg. [A-Za-z]{2,8}
- Text
- Long text
- Token
- Url
- Image
- One of a list
- Year
- Duration in seconds
- Number
- Propose event
- Paste an unsigned Nostr event for the group to sign. It is filed by kind: a profile (kind 0), a post (kind 1), a relay list, or a curated schema (kind 31889).
- The group signs it, so it takes a quorum. The author is the group and the time is now, whatever the paste says about either.
- This group has no shared key, so it cannot sign an event. Run a shared key ceremony first.
- Couldn't ask the group to sign this event.
- Unsigned event
- {"kind": 1, "tags": [], "content": "…"}
- What the group would sign
- Kind %1$s
- That isn't JSON.
- An event is a JSON object with a kind, tags and content.
- An event needs a kind.
- Kind %1$s has nowhere to go on the group's screen. It can file kinds %2$s.
- Tags must be a list of lists of strings.
- Content must be text.
- The content isn't a profile. A kind 0 carries a JSON object of profile fields.
- This schema can't be signed as it is:
- View suggestions
- Suggestions
- Everything anyone has suggested to this list, newest first. Only the group can take one up into the list.
- 1 suggestion
- %1$s suggestions
- %1$s curated
- Read from %1$s
- Asking the list's relays…
- Nobody has suggested anything to this list yet.
- Ask again
- Curated
- Suggested by %1$s on %2$s
- Taken up by the group into the list.
- Accept this suggestion
- Revise the list's entry
-
- Take this suggestion into %1$s, edited as the group wants it. The entry keeps the suggestion's identifier and points back at it, so the suggester is credited.
- The group signs an entry, so it takes a quorum. Nothing changes until one has.
- This group has no shared key, so it cannot sign an entry. Run a shared key ceremony first.
- The group already has an entry for this. Proposing again revises it in place rather than adding a second one.
- Propose entry
- Couldn't ask the group to sign this entry. Please try again.
- This entry can't be signed as it is:
- The entry can't be signed as it is.
- Kept from the suggestion
- Filled in by the suggester's app and kept as they came, the identifier among them, so that an entry accepted twice lands on the same coordinate.
- Add another
- This field is required.
- Only one value for this field.
- One word, without spaces.
- That is not a web address.
- That must be an https address.
- Pick one of the options.
- A whole number.
- More than zero.
- At least %1$s.
- At most %1$s.
- At most %1$s characters.
- Must match %1$s.
- At least one of these is needed: %1$s.
- %1$s: %2$s
-
- Curated entries
- This group hasn't accepted anything into a list yet. An entry a quorum signs is shown here, and reaches the list's readers once it is broadcast.
-
- No entries yet
- 1 entry
- %1$s entries
- In %1$s
- Suggested by %1$s
Broadcast
Sends the event to each relay below, exactly as the group signed it. Any member holding it can send it: the signature is the group's, not yours.
- Where the group has said it lives is filled in. Add or remove relays before sending.
+ The relays this app publishes to are filled in. Add or remove relays before sending.
No relays to broadcast to. Add one above.
Add a relay to broadcast to.
Sending…
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventDao.kt
index 33c48367..61775857 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventDao.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventDao.kt
@@ -77,17 +77,6 @@ interface NostrEventDao {
query: RoomRawQuery
): List
- /**
- * The same query, watched. Observed tables named explicitly because a raw query cannot
- * infer them: the events themselves, and the profiles joined onto them, so a row whose
- * author's kind 0 arrives after the row does re-emits with the name filled in.
- */
- @Transaction
- @RawQuery(observedEntities = [NostrEvent::class, press.mantra.compose.database.model.Profile::class])
- fun observeNostrEventsMatchingFilter(
- query: RoomRawQuery
- ): Flow>
-
@Transaction
@Query("SELECT * FROM NostrEvent WHERE kind in (:kinds) AND createdAt > :since ORDER BY createdAt ASC LIMIT :limit")
suspend fun getNostrEvents(
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt
index 8221ba82..f6db7723 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt
@@ -20,13 +20,6 @@ import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
-import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
-import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
-import press.mantra.compose.nostr.GroupCuratedSchema
-import press.mantra.compose.nostr.GroupNostrProfile
-import press.mantra.compose.nostr.curated.CuratedEntryEvent
-import press.mantra.compose.nostr.curated.CuratedSchemaEvent
-import press.mantra.compose.nostr.GroupRelaySet
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import press.mantra.compose.nostr.dkg.DkgRitualEvents
import press.mantra.compose.nostr.frost.FrostSigningEvents
@@ -390,82 +383,6 @@ data class ChatMessage(
*/
const val TYPE_SUBGROUP_CERTIFIED = "subgroupCertified"
- /**
- * The group signed what it says about itself on nostr.
- *
- * A system line rather than a bubble, for the reason every group-signed
- * line is one: nobody said it, the group signed it. Whoever filled the
- * form in proposed it and a quorum agreed, and the transcript of that
- * happening is the signing session's own -- this is the line saying what
- * the group now is, which is worth having in the room because a rename is
- * the kind of thing members should not have to go looking for.
- *
- * Written only for a profile the *room* signed. A kind:0 authored by
- * anybody else that reaches this room is a member's own profile
- * travelling as a rumor, which is not the group saying anything.
- */
- const val TYPE_GROUP_PROFILE_SIGNED = "groupProfileSigned"
-
- /**
- * The group signed where it lives on nostr.
- *
- * One line per list, so a session that changed three of them writes three.
- * They are not three accounts of one thing -- each names which list was
- * agreed, which is the part a reader needs -- and `applyInnerEvent` is
- * handed one event at a time with no way to know it was in a batch anyway.
- */
- const val TYPE_GROUP_RELAYS_SIGNED = "groupRelaysSigned"
-
- /**
- * Every line about the group's own nostr identity, for the one check the
- * transcript dispatches on.
- *
- * The profile and the relay lists together, because they are the same kind
- * of statement -- the group's account of itself, signed by the group -- and
- * the transcript does the same thing with both. A type missing from here
- * renders as a chat bubble, silently, looking exactly like somebody having
- * said "The group is now called Translation collective".
- */
- /**
- * The group signed something to say publicly, in its own name.
- *
- * A system line rather than a bubble, like the other two -- nobody said it,
- * the group signed it -- and the line names the post rather than repeating
- * it, because the post itself is on the group's screen where it can be read
- * whole. A quote here would be the same words twice with the second copy
- * unable to say who agreed to them.
- */
- const val TYPE_GROUP_POST_SIGNED = "groupPostSigned"
-
- /**
- * The group signed the schema of a list it curates.
- *
- * A system line like the other three, and it names the list rather than
- * describing the schema: what a reader of the room needs to know is that
- * the group now curates -- or has changed the form for -- a list called
- * so-and-so, and the schema itself is on the group's screen where its
- * fields can be read.
- */
- const val TYPE_GROUP_SCHEMA_SIGNED = "groupSchemaSigned"
-
- /**
- * The group signed an entry into a list it curates.
- *
- * A system line like the schema's, and it names the entry and the list
- * rather than the fields: what a reader of the room needs to know is
- * that the list now stands behind a title, and the entry itself is on
- * the queue screen where its fields can be read.
- */
- const val TYPE_GROUP_ENTRY_CURATED = "groupEntryCurated"
-
- val GROUP_IDENTITY_TYPES = setOf(
- TYPE_GROUP_PROFILE_SIGNED,
- TYPE_GROUP_RELAYS_SIGNED,
- TYPE_GROUP_POST_SIGNED,
- TYPE_GROUP_SCHEMA_SIGNED,
- TYPE_GROUP_ENTRY_CURATED,
- )
-
/**
* Every subgroup line, for the one check the transcript dispatches on.
*
@@ -1016,22 +933,6 @@ data class ChatMessage(
createdAt: Instant,
groupSignedEventId: HexKey? = null,
): ChatMessage? {
- // The last resort, named because one arm reaches it deliberately rather
- // than by falling off the end: a kind:1 the room did not sign is a
- // member's own note travelling through the room, and it has always
- // rendered as this. See the `TextNoteEvent` arm.
- fun unsupported() = ChatMessage(
- giftWrapPayloadId = null,
- messageType = "unsupported",
- marmotGroupEventId = marmotGroupEventId,
- marmotInnerEventId = marmotInnerEventId,
- senderPublicKey = senderPublicKey,
- isUserMessage = isUserMessage,
- chatRoomId = groupId,
- createdAt = createdAt,
- content = event.toJson(),
- )
-
return when (event.kind) {
ChatEvent.KIND -> {
ChatMessage(
@@ -1444,197 +1345,19 @@ data class ChatMessage(
)
}
- // The group saying who it is on nostr. A room's id is the pubkey
- // it signs as, so a group has an identity from the moment it has a
- // key; this is the kind:0 describing it, signed by the group's own
- // quorum like everything else it says.
- //
- // **Only when the room itself signed it.** This arm is reached both
- // from a completed signing session, where the author is the room by
- // construction, and from an arriving inner event, where any member
- // could have sent a rumor of this kind -- and it cannot tell which.
- // A member's own kind:0 travelling through a room is their profile,
- // not the group's, and gets no line here.
- //
- // No row is written. The event is already on file as a
- // `GroupSignedEvent` -- `FrostSigningManager.complete` records the
- // batch before it applies it -- and `GroupNostrProfile` reads the
- // group's profile straight off those. A `Profile` row is not an
- // option anyway: it hangs off `NostrEvent` by foreign key, and a
- // group-signed event is not one.
- MetadataEvent.KIND -> {
- if (!event.pubKey.equals(groupId, ignoreCase = true)) return null
-
- val profile = GroupNostrProfile.of(
- signedEvent = GroupSignedEvent.fromEvent(event, chatRoomId = groupId),
- chatRoomId = groupId,
- ) ?: return null
-
+ else -> {
ChatMessage(
giftWrapPayloadId = null,
- messageType = TYPE_GROUP_PROFILE_SIGNED,
+ messageType = "unsupported",
marmotGroupEventId = marmotGroupEventId,
marmotInnerEventId = marmotInnerEventId,
senderPublicKey = senderPublicKey,
isUserMessage = isUserMessage,
chatRoomId = groupId,
createdAt = createdAt,
- content = profile.name()?.let { "The group is now called $it" }
- ?: "The group signed a new profile for itself"
+ content = event.toJson(),
)
}
-
- // The group saying something publicly, in its own name -- which
- // is a different thing from a member saying something in the room.
- // A room's messages are `ChatEvent`s; this is a kind:1 authored by
- // the room's own key, so any reader can check the group said it and
- // that no single member could have.
- //
- // Verified like the arms below and for the same reason: a rumor
- // carries an empty signature and any pubkey its sender likes, so an
- // author check alone would let one member put words in the group's
- // mouth in its own transcript.
- //
- // **Unlike those arms, a kind:1 that fails falls through to
- // `unsupported` rather than vanishing.** A member's kind:0 or relay
- // list passing through a room is identity plumbing nobody sent on
- // purpose and silence is the right treatment; a kind:1 is content,
- // somebody meant it, and it has rendered as an unsupported event
- // since before any of this existed. Dropping it here would be this
- // change quietly deleting messages it has no business touching.
- TextNoteEvent.KIND -> {
- val signed = GroupSignedEvent.fromEvent(event, chatRoomId = groupId)
- if (!signed.verifies()) return unsupported()
-
- ChatMessage(
- giftWrapPayloadId = null,
- messageType = TYPE_GROUP_POST_SIGNED,
- marmotGroupEventId = marmotGroupEventId,
- marmotInnerEventId = marmotInnerEventId,
- senderPublicKey = senderPublicKey,
- isUserMessage = isUserMessage,
- chatRoomId = groupId,
- createdAt = createdAt,
- content = "The group posted in its own name"
- )
- }
-
- // The group saying where it lives on nostr: which relays carry
- // its work, take its messages, answer a search of it, and which it
- // will not talk to.
- //
- // **Verified, not just attributed.** This arm is reached from a
- // completed signing session, where the author is the room by
- // construction, and from an arriving inner event, where it is
- // whatever the sender wrote -- and it cannot tell which. A rumor
- // carries an empty signature and any pubkey its sender likes, so an
- // author check alone would let one member put "the group signed its
- // general relay list" in the transcript. `verifies` is what makes
- // the line mean what it says: the room's key over these tags.
- //
- // A member's own relay list travelling through the room fails the
- // author half and gets no line, which is right -- it is their list,
- // not the group's.
- //
- // No row is written, and nothing is parsed beyond the check. The
- // events are already on file as `GroupSignedEvent` and
- // `GroupRelayList` reads the lists straight off those; this arm
- // exists so a signed relay list is a line in the transcript rather
- // than raw JSON in a bubble.
- in GroupRelaySet.KINDS -> {
- val signed = GroupSignedEvent.fromEvent(event, chatRoomId = groupId)
- if (!signed.verifies()) return null
-
- val set = GroupRelaySet.entries.firstOrNull { it.kind == event.kind }
- ?: return null
-
- ChatMessage(
- giftWrapPayloadId = null,
- messageType = TYPE_GROUP_RELAYS_SIGNED,
- marmotGroupEventId = marmotGroupEventId,
- marmotInnerEventId = marmotInnerEventId,
- senderPublicKey = senderPublicKey,
- isUserMessage = isUserMessage,
- chatRoomId = groupId,
- createdAt = createdAt,
- content = "The group signed its ${set.name.lowercase()} relay list"
- )
- }
-
- // The group defining a list it curates: the kind 31889 that
- // suggestions from anyone reply to and only the group's own key can
- // accept into the list.
- //
- // Verified and parsed, and null on either failing, the way the
- // profile arm is. A rumor of this kind from a member is not the
- // group curating anything, and a schema the room signed but that no
- // client could act on -- no visibility, no fields -- is not worth a
- // line saying it did. `GroupCuratedSchema` refuses the same event
- // on the group's screen, so the transcript and the screen agree.
- CuratedSchemaEvent.KIND -> {
- val curated = GroupCuratedSchema.of(
- signedEvent = GroupSignedEvent.fromEvent(event, chatRoomId = groupId),
- chatRoomId = groupId,
- ) ?: return null
-
- ChatMessage(
- giftWrapPayloadId = null,
- messageType = TYPE_GROUP_SCHEMA_SIGNED,
- marmotGroupEventId = marmotGroupEventId,
- marmotInnerEventId = marmotInnerEventId,
- senderPublicKey = senderPublicKey,
- isUserMessage = isUserMessage,
- chatRoomId = groupId,
- createdAt = createdAt,
- content = "The group signed the curated schema for " +
- curated.schema.displayName()
- )
- }
-
- // The group taking an entry into a list it curates: the kind
- // 31890 that only its own key can publish, and the thing the
- // queue screen marks a suggestion "curated" by once it is read
- // back off a relay.
- //
- // Verified, for the reason the schema arm is: a rumor of this
- // kind from a member is not the group curating anything. Named
- // after the list where this device holds its schema -- looked
- // up by the coordinate the entry replies to -- and after the
- // entry alone where it does not, since the entry is what was
- // signed either way.
- CuratedEntryEvent.CANONICAL_KIND -> {
- val signed = GroupSignedEvent.fromEvent(event, chatRoomId = groupId)
- if (!signed.verifies()) return null
-
- val entry = CuratedEntryEvent.headline(event)
- val title = entry.title.ifBlank { entry.identifier }.ifBlank { "an entry" }
-
- val list = runCatching {
- GroupCuratedSchema.newestPerListAmong(
- signedEvents = database.groupSignedEventDao()
- .getByChatRoomIdAndKind(groupId, CuratedSchemaEvent.KIND),
- chatRoomId = groupId,
- ).firstOrNull { it.isRepliedToBy(event.tags) }
- }.getOrNull()
-
- ChatMessage(
- giftWrapPayloadId = null,
- messageType = TYPE_GROUP_ENTRY_CURATED,
- marmotGroupEventId = marmotGroupEventId,
- marmotInnerEventId = marmotInnerEventId,
- senderPublicKey = senderPublicKey,
- isUserMessage = isUserMessage,
- chatRoomId = groupId,
- createdAt = createdAt,
- content = if (list != null) {
- "The group accepted $title into ${list.schema.displayName()}"
- } else {
- "The group accepted $title into a list it curates"
- }
- )
- }
-
- else -> unsupported()
}
}
}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/NostrEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/NostrEvent.kt
index 8b514f6b..007681a1 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/NostrEvent.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/NostrEvent.kt
@@ -478,17 +478,6 @@ data class NostrEvent(
}
}
- /** The row as the event it was stored from, for readers written against the wire shape. */
- fun toEvent(): Event = Event(
- id = id,
- pubKey = pubKey,
- createdAt = createdAt.epochSeconds,
- kind = kind,
- tags = tags,
- content = content,
- sig = sig,
- )
-
fun toTextNoteEvent(): TextNoteEvent {
return TextNoteEvent(
id = id,
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalChatRoom.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalChatRoom.kt
index 1d0a7083..9ca34fd7 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalChatRoom.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalChatRoom.kt
@@ -117,8 +117,7 @@ data class LocalChatRoom(
lastChatMessage.messageType in ChatMessage.FROST_TYPES ||
lastChatMessage.messageType in ChatMessage.CHRONICLE_TYPES ||
lastChatMessage.messageType in ChatMessage.MEMBERSHIP_TYPES ||
- lastChatMessage.messageType in ChatMessage.SUBGROUP_TYPES ||
- lastChatMessage.messageType in ChatMessage.GROUP_IDENTITY_TYPES
+ lastChatMessage.messageType in ChatMessage.SUBGROUP_TYPES
) {
val isAuthored = lastChatMessage.messageType in ChatMessage.DKG_AUTHORED_TYPES ||
lastChatMessage.messageType in ChatMessage.FROST_AUTHORED_TYPES
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt
index fb511cec..d0143ec3 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt
@@ -9,14 +9,6 @@ import press.mantra.compose.managers.FrostSigningManager
import press.mantra.compose.managers.GroupKeyStateManager
import press.mantra.compose.managers.MarmotGroupCreation
import press.mantra.compose.managers.SubgroupManager
-import press.mantra.compose.nostr.GroupNostrProfile
-import press.mantra.compose.nostr.GroupCuratedEntry
-import press.mantra.compose.nostr.GroupCuratedSchema
-import press.mantra.compose.nostr.GroupPost
-import press.mantra.compose.nostr.curated.CuratedEntryEvent
-import press.mantra.compose.nostr.curated.CuratedSchemaEvent
-import press.mantra.compose.nostr.GroupRelayList
-import press.mantra.compose.nostr.GroupRelaySet
import press.mantra.compose.database.model.ChatMessage
import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.GiftWrapPayload
@@ -33,8 +25,6 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.Kind
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
-import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
-import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
@@ -130,75 +120,6 @@ class DatabaseChatRepository(
null
}
- override suspend fun groupNostrProfile(chatRoomId: String): GroupNostrProfile? = try {
- GroupNostrProfile.newestAmong(
- signedEvents = database.groupSignedEventDao()
- .getByChatRoomIdAndKind(chatRoomId, MetadataEvent.KIND),
- chatRoomId = chatRoomId,
- )
- } catch (e: Throwable) {
- // Reading one parses content off the wire and checks a signature, and a
- // room that shows no profile is wrong and survivable where one that will
- // not open is neither -- the same trade the readings above make.
- logger.e("Error reading the nostr profile of $chatRoomId", e)
- null
- }
-
- override suspend fun groupRelayLists(chatRoomId: String): List = try {
- // One query per set rather than one for the room: the four kinds are
- // indexed and a room's whole signed history is every artifact, chunk and
- // translation it has ever agreed.
- GroupRelaySet.entries.map { set ->
- GroupRelayList.newestAmong(
- signedEvents = database.groupSignedEventDao()
- .getByChatRoomIdAndKind(chatRoomId, set.kind),
- chatRoomId = chatRoomId,
- set = set,
- )
- }
- } catch (e: Throwable) {
- logger.e("Error reading the relay lists of $chatRoomId", e)
- // Four unsigned, empty sets: the screen draws "not set yet" rather than
- // failing to draw, which is the trade every reading above makes.
- GroupRelayList.allAmong(emptyList(), chatRoomId)
- }
-
- override suspend fun groupPosts(chatRoomId: String): List = try {
- GroupPost.newestFirstAmong(
- signedEvents = database.groupSignedEventDao()
- .getByChatRoomIdAndKind(chatRoomId, TextNoteEvent.KIND),
- chatRoomId = chatRoomId,
- )
- } catch (e: Throwable) {
- logger.e("Error reading the posts of $chatRoomId", e)
- emptyList()
- }
-
- override suspend fun groupCuratedSchemas(chatRoomId: String): List = try {
- GroupCuratedSchema.newestPerListAmong(
- signedEvents = database.groupSignedEventDao()
- .getByChatRoomIdAndKind(chatRoomId, CuratedSchemaEvent.KIND),
- chatRoomId = chatRoomId,
- )
- } catch (e: Throwable) {
- logger.e("Error reading the curated schemas of $chatRoomId", e)
- emptyList()
- }
-
- override suspend fun groupCuratedEntries(chatRoomId: String): List = try {
- // The schemas first, because an entry is read against the list it
- // replies to and there is nothing to read one by without them.
- GroupCuratedEntry.newestPerEntryAmong(
- signedEvents = database.groupSignedEventDao()
- .getByChatRoomIdAndKind(chatRoomId, CuratedEntryEvent.CANONICAL_KIND),
- chatRoomId = chatRoomId,
- schemas = groupCuratedSchemas(chatRoomId),
- )
- } catch (e: Throwable) {
- logger.e("Error reading the curated entries of $chatRoomId", e)
- emptyList()
- }
-
override suspend fun groupSignedEvent(chatRoomId: String, id: HexKey): GroupSignedEvent? = try {
database.groupSignedEventDao().getById(id)?.takeIf { it.chatRoomId == chatRoomId }
} catch (e: Throwable) {
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt
index 44c45d97..1d82481b 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt
@@ -749,12 +749,6 @@ class DatabaseNostrRepository(
}
}
- override suspend fun observeNostrEventsMatching(synchronizationFilter: SynchronizationFilter): Flow> {
- return database.nostrEventDao().observeNostrEventsMatchingFilter(
- NostrEventFilterQuery.build(synchronizationFilter)
- )
- }
-
override suspend fun getNegentropicNostrFeedIds(
synchronizationFilters: Array,
applyLimits: Boolean
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/extensions/Member.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/extensions/Member.kt
index 070e548a..b3fdf441 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/extensions/Member.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/extensions/Member.kt
@@ -9,23 +9,6 @@ private const val SHORTENED_PUBLIC_KEY_LENGTH = 8
/** A public key cut to something readable, for where no profile has arrived yet. */
fun HexKey.shortened(): String = take(SHORTENED_PUBLIC_KEY_LENGTH)
-/**
- * A long hex value in groups of eight, which is what makes it readable and what
- * lets it wrap at all.
- *
- * Both halves matter. A 64-character key is compared against another member's
- * screen a character at a time, and the eye loses its place in an undivided run
- * of hex -- the same reason a fingerprint or an account number is grouped.
- *
- * And a run of hex has no space in it, so Compose has nowhere to break the line:
- * it lays the whole thing out on one and lets it run off the side of the sheet,
- * where the end of the key cannot be read at all. The spaces are the break
- * opportunities. Nothing is copied from these -- the copy button takes the
- * signed event, which is not grouped -- so the display can be shaped for reading
- * without a paste losing the value.
- */
-fun HexKey.inComparableGroups(): String = chunked(8).joinToString(" ")
-
/**
* What to call the member behind this public key on screen.
*
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/CuratedSuggestion.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/CuratedSuggestion.kt
deleted file mode 100644
index a7c1ff88..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/CuratedSuggestion.kt
+++ /dev/null
@@ -1,115 +0,0 @@
-package press.mantra.compose.nostr
-
-import press.mantra.compose.database.GENESIS_AT
-import press.mantra.compose.database.model.NostrEvent
-import press.mantra.compose.database.model.Profile
-import press.mantra.compose.database.model.intermdiate.LocalNostrEvent
-import press.mantra.compose.extensions.shortened
-import press.mantra.compose.nostr.curated.CuratedEntry
-import press.mantra.compose.nostr.curated.CuratedEntryEvent
-import kotlin.time.Instant
-
-/**
- * One suggestion to a list the group curates, as somebody published it and as
- * this device has read it.
- *
- * The schema is the group asking; these are the answers. Anyone may publish one
- * -- that is the point of a list -- so unlike everything else on the group's
- * screen a suggestion is not the group's word and was never signed in the room.
- * It is a `NostrEvent` off a relay, signature-checked on the way in like every
- * other, and then checked against the schema it replies to, because a relay is
- * an untrusted place and an entry that does not satisfy the schema is not shown
- * with the bad parts blanked out. See [CuratedEntryEvent].
- *
- * ### The queue and the list
- *
- * The NIP's words: the canonical entries are the list, and the suggestions are
- * its queue. This is the queue. A suggestion is marked [isCurated] when a
- * canonical entry the *group* signed shares its identifier -- the coordinate
- * both land on -- which is how a reader tells what the group has already taken
- * up from what is still waiting. Only the group's own canonical entries count:
- * curation is the one power a schema does not delegate, and a kind 31890 from
- * anybody else is somebody else's list.
- */
-data class CuratedSuggestion(
- /** The entry, keyed by the schema's field names. */
- val entry: CuratedEntry,
- /** The event as it was stored, for the id and the tags: what a canonical entry points back at. */
- val event: NostrEvent,
- /** The suggester's profile where one has arrived, else null. */
- val suggester: Profile?,
- /** Whether a canonical entry by the group shares this suggestion's identifier. */
- val isCurated: Boolean,
-) {
- val id: String get() = event.id
-
- val suggestedAt: Instant get() = event.createdAt
-
- /**
- * What to call the suggester: their name where a profile has arrived and is
- * a real one, else a short key. A placeholder profile is the one the indexer
- * writes the moment a pubkey is first seen, and it is named "LOADING..." --
- * which is not a name.
- */
- fun suggesterName(): String =
- suggester?.takeUnless { it.createdAt == GENESIS_AT }?.humanReadableNameOrPubkey()
- ?: entry.pubKey.shortened()
-
- companion object {
- /**
- * The queue of [schema], read off [events], newest first.
- *
- * Two readings over the same rows. Every kind 31888 that satisfies the
- * schema and replies to its coordinate is a suggestion; every kind 31890
- * that does the same *and* is signed by the group is a canonical entry.
- * Both kinds are addressable, so among events sharing a coordinate the
- * newest is the entry and the rest are its history -- the client-side
- * merge that realises an edit, since different relays hold different
- * latest versions. Then the canonical identifiers mark the suggestions.
- *
- * Newest first because that is the order a queue is read in, and the id
- * breaking every tie so two devices reading the same events in different
- * orders show the same thing.
- */
- fun queueOf(schema: GroupCuratedSchema, events: List): List {
- val coordinate = schema.coordinate()
-
- val entries = events.mapNotNull { local ->
- val event = local.nostrEvent.toEvent()
-
- when (event.kind) {
- CuratedEntryEvent.SUGGESTION_KIND ->
- CuratedEntryEvent.suggestion(event, schema.schema, coordinate)
- CuratedEntryEvent.CANONICAL_KIND ->
- CuratedEntryEvent.canonical(event, schema.schema, coordinate, curator = schema.publicKey)
- else -> null
- }?.let { entry -> local to entry }
- }
- .groupBy { (_, entry) -> entry.coordinate() }
- .values
- .map { versions ->
- versions.maxWith(compareBy({ (_, entry) -> entry.createdAt }, { (_, entry) -> entry.id }))
- }
-
- val curatedIdentifiers = entries
- .filter { (_, entry) -> entry.isCanonical }
- .map { (_, entry) -> entry.identifier }
- .toSet()
-
- return entries
- .filterNot { (_, entry) -> entry.isCanonical }
- .map { (local, entry) ->
- CuratedSuggestion(
- entry = entry,
- event = local.nostrEvent,
- suggester = local.profile,
- isCurated = entry.identifier in curatedIdentifiers,
- )
- }
- .sortedWith(
- compareByDescending { it.entry.createdAt }
- .thenByDescending { it.entry.id }
- )
- }
- }
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedEntry.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedEntry.kt
deleted file mode 100644
index 04caf549..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedEntry.kt
+++ /dev/null
@@ -1,134 +0,0 @@
-package press.mantra.compose.nostr
-
-import com.vitorpamplona.quartz.nip01Core.core.HexKey
-import press.mantra.compose.database.model.GroupSignedEvent
-import press.mantra.compose.nostr.curated.CuratedEntry
-import press.mantra.compose.nostr.curated.CuratedEntryEvent
-import kotlin.time.Instant
-
-/**
- * A canonical entry the group signed: a suggestion it accepted into one of
- * the lists it curates, as the list's own word.
- *
- * Kind 31890 -- see [CuratedEntryEvent] -- authored by the room's own key,
- * which is the one key a list's readers accept a canonical entry from. Where
- * the schema is the group asking and the queue is what strangers answered,
- * this is what the group *decided*: the entries the list stands behind, each
- * one a quorum's signature over a suggestion, edited or not, with a pointer
- * back at who suggested it.
- *
- * ### Read against the list it replies to
- *
- * An entry replies to a schema by coordinate, so the reading needs the
- * group's schemas as well as its signed events: the one whose coordinate the
- * entry's `a` root names is the list it belongs to, and the entry is then
- * checked against that list's fields exactly as `CuratedSuggestion.queueOf`
- * checks a copy that came back off a relay. The two readings agree by
- * construction, so what this screen shows as accepted is what the queue will
- * mark as curated once the entry has been broadcast and read back.
- *
- * An entry replying to a list this device holds no schema for is not shown:
- * without the schema there are no fields to read it by, and no way to say
- * which list it is in.
- *
- * ### One per coordinate
- *
- * The kind is addressable, so re-curating an entry -- to take in a
- * suggester's edit, or to fix the group's own -- is a newer event under the
- * same `d`, and the newest is the entry the way the newest schema is the
- * list. The coordinate is `31890::` whatever list the entry replies
- * to, so the same `d` accepted into two of the group's lists is one
- * coordinate and the newer event replaces the older, on relays and here
- * alike. An identifier is the entry's rather than the list's, which is the
- * reference client's reading too: its `d` is an external id that names the
- * same film in every list.
- *
- * ### Nothing here has reached a relay
- *
- * The same caveat as the schema, and here it is the one that matters: the
- * queue screen marks a suggestion curated only from a copy read back off the
- * list's relays, so until a member broadcasts an entry from this section the
- * queue still shows it waiting. The broadcast button under each card is how
- * it gets there.
- */
-data class GroupCuratedEntry(
- /** The group's statement, whole, with the signature that makes it one. */
- val signedEvent: GroupSignedEvent,
- /** The list the entry is in, as the group last signed its schema. */
- val list: GroupCuratedSchema,
- /** What the statement says, keyed by the list's fields, and where it came from. */
- val entry: CuratedEntry,
-) {
- /** The group's nostr identity, which for a derived room is also its id. */
- val publicKey: HexKey get() = signedEvent.publicKey
-
- /** When the group signed it, which is the order entries are read in. */
- val signedAt: Instant get() = signedEvent.createdAt
-
- /** The entry's `d`, which with the group's key is its coordinate. */
- val identifier: String get() = entry.identifier
-
- val title: String get() = entry.title
-
- /** `31890::`. Two events sharing one are versions of one entry. */
- fun coordinate(): String = entry.coordinate()
-
- companion object {
- /**
- * The reading of one signed event against the group's [schemas], or
- * null when it is not one of these.
- *
- * Four ways to be null and they are all the same refusal: the wrong
- * kind, a signature that does not check out as [chatRoomId]'s, a root
- * naming no list this device holds, or an entry the list's own fields
- * refuse. A caller gets an entry the group really signed into a list it
- * really curates, or gets nothing.
- */
- fun of(
- signedEvent: GroupSignedEvent,
- chatRoomId: String,
- schemas: List,
- ): GroupCuratedEntry? {
- if (signedEvent.kind != CuratedEntryEvent.CANONICAL_KIND) return null
- if (!signedEvent.verifies()) return null
-
- val list = schemas.firstOrNull { it.isRepliedToBy(signedEvent.tags) } ?: return null
-
- val entry = CuratedEntryEvent.canonical(
- event = signedEvent.toEvent(),
- schema = list.schema,
- coordinate = list.coordinate(),
- curator = list.publicKey,
- ) ?: return null
-
- return GroupCuratedEntry(signedEvent = signedEvent, list = list, entry = entry)
- }
-
- /**
- * The newest version of every entry [chatRoomId] signed among
- * [signedEvents], most recently signed first.
- *
- * Newest per coordinate because the kind is addressable and a
- * re-curation is a newer event under the same `d`; newest first so that
- * what the group last decided is at the top, and the id breaking every
- * tie so that two devices reading the same events in different orders
- * show the same thing.
- */
- fun newestPerEntryAmong(
- signedEvents: List,
- chatRoomId: String,
- schemas: List,
- ): List =
- signedEvents
- .mapNotNull { of(it, chatRoomId, schemas) }
- .groupBy { it.coordinate() }
- .values
- .map { versions ->
- versions.maxWith(compareBy({ it.signedAt }, { it.signedEvent.id }))
- }
- .sortedWith(
- compareByDescending { it.signedAt }
- .thenByDescending { it.signedEvent.id }
- )
- }
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedSchema.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedSchema.kt
deleted file mode 100644
index 1622e453..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedSchema.kt
+++ /dev/null
@@ -1,206 +0,0 @@
-package press.mantra.compose.nostr
-
-import press.mantra.compose.database.model.GroupSignedEvent
-import press.mantra.compose.database.model.types.SynchronizationFilter
-import press.mantra.compose.nostr.curated.CuratedEntryEvent
-import press.mantra.compose.nostr.curated.CuratedEntryProblem
-import press.mantra.compose.nostr.curated.CuratedSchema
-import press.mantra.compose.nostr.curated.CuratedSchemaEvent
-import press.mantra.compose.nostr.curated.CuratedSuggestionRef
-import com.vitorpamplona.quartz.nip01Core.core.HexKey
-import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
-import kotlin.time.Instant
-
-/**
- * A curated schema the group signed: a list it curates, as the group defined it.
- *
- * Kind 31889 -- see [CuratedSchemaEvent] -- authored by the room's own key. That
- * makes the group the curator of the list the schema describes: anyone may reply
- * to it with a suggestion, and only an event signed by the same key counts as the
- * list itself. For a group that means the same thing everything else on its screen
- * means: a quorum decides what goes in.
- *
- * ### Several per group, one per identifier
- *
- * Unlike a profile, of which a group has one, a group may curate several lists,
- * and each is its own coordinate `31889::`. So the reading is per
- * identifier: among the events sharing a `d` the newest wins, the way it does for
- * a profile, because the kind is addressable and re-signing one is how a schema
- * is edited. Two different identifiers are two lists and both are shown.
- *
- * Read off [GroupSignedEvent] and gated on [GroupSignedEvent.verifies], for the
- * reasons `GroupNostrProfile` gives at length: a group-signed event is not a
- * `NostrEvent`, and only an event the room itself signed is the group speaking.
- *
- * ### Nothing here has reached a relay
- *
- * The same caveat as the profile, the relay lists and the posts. A schema is the
- * one statement here that exists to be *replied to* by strangers, so until it is
- * published the list it defines has no queue -- the relays it names say where
- * suggestions would go, and nothing yet puts the schema there for them to find.
- * The queue is read all the same, by `CuratedSuggestion.queueOf` over whatever
- * those relays hold for [coordinate], so a schema that reached a relay by some
- * other road has its suggestions shown here.
- */
-data class GroupCuratedSchema(
- /** The group's statement, whole, with the signature that makes it one. */
- val signedEvent: GroupSignedEvent,
- /** What the statement says, already checked and normalized. */
- val schema: CuratedSchema,
-) {
- /** The group's nostr identity, which for a derived room is also its id. */
- val publicKey: HexKey get() = signedEvent.publicKey
-
- /** When the group signed it, which is what decides the newest of two. */
- val signedAt: Instant get() = signedEvent.createdAt
-
- /** The schema's `d`, and the last part of its coordinate. */
- val identifier: String get() = schema.identifier
-
- /** What a suggestion's `a` root names to reply to this list. */
- fun coordinate(): String = CuratedSchemaEvent.coordinate(publicKey, identifier)
-
- /**
- * Whether an entry carrying [tags] replies to this list: its `a` tags
- * include this coordinate. How a reader holding several of the group's
- * schemas tells which one an entry belongs to.
- */
- fun isRepliedToBy(tags: Array>): Boolean =
- tags.any { it.size > 1 && it[0] == COORDINATE_TAG && it[1] == coordinate() }
-
- /**
- * What to ask a relay for to read this list's queue: the NIP's two queries,
- * as one request.
- *
- * Everything suggested to the list is whatever anyone published in reply
- * to the coordinate; the list itself is what the *group* published in reply
- * to it. The `authors` on the second is redundant with the rule that only
- * the curator's canonical entries count -- `CuratedSuggestion.queueOf`
- * enforces that on the way in regardless -- and is there to spare the relay
- * the work. Both land in the same table, and [queueReadFilter] is how they
- * are read back.
- */
- fun queueFilters(): Array = arrayOf(
- SynchronizationFilter(
- kinds = arrayOf(schema.suggestionKind),
- tags = mapOf(COORDINATE_TAG to listOf(coordinate())),
- limit = QUEUE_LIMIT,
- ),
- SynchronizationFilter(
- kinds = arrayOf(CuratedSchemaEvent.CANONICAL_KIND),
- authors = arrayOf(publicKey),
- tags = mapOf(COORDINATE_TAG to listOf(coordinate())),
- limit = QUEUE_LIMIT,
- ),
- )
-
- /**
- * What to watch locally for the same rows: both kinds, replying to the
- * coordinate. One filter rather than two because the local store is a
- * single table and the reading tells the kinds apart itself.
- */
- fun queueReadFilter(): SynchronizationFilter = SynchronizationFilter(
- kinds = arrayOf(schema.suggestionKind, CuratedSchemaEvent.CANONICAL_KIND),
- tags = mapOf(COORDINATE_TAG to listOf(coordinate())),
- )
-
- /**
- * The event to ask the group to sign to take an entry into the list:
- * [values] as this schema lays them out, rooted at [coordinate], with the
- * group as the curator and [source] as the suggestion it came from.
- *
- * The group is the curator, so its key is the `p` on the root and the only
- * author [verifyCanonical] will accept. See [CuratedEntryEvent.canonicalTemplate]
- * for what is and is not done to the values on the way through.
- */
- fun canonicalTemplate(
- values: Map>,
- source: CuratedSuggestionRef?,
- ): EventTemplate<*> = CuratedEntryEvent.canonicalTemplate(
- schema = schema,
- coordinate = coordinate(),
- curator = publicKey,
- values = values,
- source = source,
- )
-
- /**
- * Every reason this list's readers would refuse [template] as one of its
- * own entries, or nothing.
- *
- * The same check `CuratedSuggestion.queueOf` makes on a kind 31890 off a
- * relay, made before the group is asked to sign one: a signature is the
- * most expensive thing this app does, and an entry the queue would then
- * refuse to count is a quorum spent on nothing.
- */
- fun verifyCanonical(template: EventTemplate<*>): List =
- CuratedEntryEvent.verifyCanonical(
- event = CuratedEntryEvent.preview(template, publicKey),
- schema = schema,
- coordinate = coordinate(),
- curator = publicKey,
- )
-
- companion object {
- /** The tag a reply carries its schema's coordinate in. */
- private const val COORDINATE_TAG = "a"
-
- /**
- * How much of a queue one request asks for: the reference client's
- * window, and more than any list here is likely to have. NIP-01 scopes
- * a limit to the stored events a relay sends before EOSE, so a longer
- * queue is read on the next visit rather than never.
- */
- private const val QUEUE_LIMIT = 500
-
- /**
- * The reading of one signed event, or null when it is not one of these.
- *
- * Three ways to be null and they are all the same refusal: the wrong
- * kind, a signature that does not check out as [chatRoomId]'s, or tags
- * that do not make a usable schema. A caller gets a schema the group
- * really signed and a client could really act on, or gets nothing.
- */
- fun of(signedEvent: GroupSignedEvent, chatRoomId: String): GroupCuratedSchema? {
- if (signedEvent.kind != CuratedSchemaEvent.KIND) return null
- if (!signedEvent.verifies()) return null
-
- val schema = CuratedSchemaEvent.parse(signedEvent.toEvent()) ?: return null
-
- return GroupCuratedSchema(signedEvent = signedEvent, schema = schema)
- }
-
- /**
- * The newest schema per identifier [chatRoomId] signed among
- * [signedEvents], most recently signed list first.
- *
- * Newest per identifier because the kind is addressable and an edit is a
- * newer event under the same `d`; newest list first so that an edit
- * surfaces, and the id breaking every tie so that two devices reading the
- * same events in different orders show the same thing.
- */
- fun newestPerListAmong(
- signedEvents: List,
- chatRoomId: String,
- ): List =
- signedEvents
- .mapNotNull { of(it, chatRoomId) }
- .groupBy { it.identifier }
- .values
- .map { versions ->
- versions.maxWith(compareBy({ it.signedAt }, { it.signedEvent.id }))
- }
- .sortedWith(
- compareByDescending { it.signedAt }
- .thenByDescending { it.signedEvent.id }
- )
-
- /**
- * The event to ask the group to sign for [schema].
- *
- * Built from the schema alone -- see [CuratedSchemaEvent.template] for
- * why an edit does not carry the last event's tags forward.
- */
- fun template(schema: CuratedSchema): EventTemplate<*> = CuratedSchemaEvent.template(schema)
- }
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupEventProposal.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupEventProposal.kt
deleted file mode 100644
index 52c1ca3c..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupEventProposal.kt
+++ /dev/null
@@ -1,188 +0,0 @@
-package press.mantra.compose.nostr
-
-import press.mantra.compose.network.serialization.CommonJson
-import press.mantra.compose.nostr.curated.CuratedSchemaEvent
-import press.mantra.compose.nostr.curated.CuratedSchemaProblem
-import com.vitorpamplona.quartz.nip01Core.core.Event
-import com.vitorpamplona.quartz.nip01Core.core.Kind
-import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
-import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
-import kotlinx.serialization.json.JsonArray
-import kotlinx.serialization.json.JsonElement
-import kotlinx.serialization.json.JsonObject
-import kotlinx.serialization.json.JsonPrimitive
-import kotlinx.serialization.json.contentOrNull
-import kotlinx.serialization.json.intOrNull
-
-/**
- * An unsigned event a member pasted for the group to sign, read and checked
- * before a quorum is asked for it.
- *
- * The typed editors -- the profile form, the post composer, the relay and
- * schema editors -- each build one kind of event and know what it needs. This
- * is the untyped way in: the event as JSON, the way another tool prints it
- * (`npm run schema:dry` in the bitcoin.mov repo prints exactly the schema event
- * it would publish), pasted whole. What comes out is the same thing the editors
- * produce, a kind, tags and content for `FrostSigningRepository.proposeSigning`,
- * and it is filed the same way when the quorum signs: `FrostSigningManager.complete`
- * records it as a `GroupSignedEvent` and the group's screen reads its profile,
- * posts, relay lists and schemas straight off those by kind.
- *
- * ### What is ignored
- *
- * `id`, `pubkey`, `sig` and `created_at`. The group is the author -- the
- * session resolves the room's key and hashes the id over it -- and the time is
- * when the session opens, which is what every typed editor does too. A pasted
- * `created_at` in particular has to go: the kinds here are replaceable or
- * addressable, so a stale one would make the new event *older* than the last and
- * the proposal would look as though it did nothing. The screen says so.
- *
- * ### What is refused, and why before rather than after
- *
- * Only kinds the group's screen has a place for, and only events that place
- * would accept. A signature is the most expensive thing this app does, and the
- * readers refuse rather than repair: a kind 0 whose content is not a profile,
- * a blank post, a schema with no visibility would each be signed, filed and
- * shown nowhere, with nothing to tell the member their quorum was spent on
- * nothing. So every check a reader makes is made here first, on the paste.
- */
-object GroupEventProposal {
- /**
- * The kinds a pasted event may be: exactly the ones the group's screen reads.
- *
- * Not the kinds `ChatMessage.applyInnerEvent` has an arm for. A subgroup
- * certificate or a key state event has invariants a paste cannot be trusted
- * to meet and a place in the room's life that a form is not, and the
- * nip30303 kinds have editors of their own. The line is "has a section on the
- * group's screen", because that is where a member goes to see it landed.
- */
- val ACCEPTED_KINDS: Set =
- setOf(MetadataEvent.KIND, TextNoteEvent.KIND, CuratedSchemaEvent.KIND) + GroupRelaySet.KINDS
-
- sealed interface Reading
-
- /** An event the group could sign and the screen could show. */
- class Ready(
- val kind: Kind,
- val tags: Array>,
- val content: String,
- ) : Reading {
- /**
- * The event as the signing screen will describe it, for a preview before
- * the button is pressed. The id, author and signature are placeholders:
- * `ProposedEvent.summarize` reads none of them.
- */
- fun preview(): Event = Event(
- id = "",
- pubKey = "",
- createdAt = 0,
- kind = kind,
- tags = tags,
- content = content,
- sig = "",
- )
- }
-
- /** Why the paste cannot be proposed as it is. */
- sealed interface Refused : Reading {
- data object NotJson : Refused
-
- /** JSON, but an array, a string, a number -- anything but an object. */
- data object NotAnObject : Refused
-
- data object NoKind : Refused
-
- data class KindNotAccepted(val kind: Kind) : Refused
-
- /** `tags` present but not a list of lists of strings. */
- data object TagsMalformed : Refused
-
- /** `content` present but not a string. */
- data object ContentNotText : Refused
-
- /** A kind 0 whose content will not parse as a profile. */
- data object ProfileUnreadable : Refused
-
- /** A kind 1 with nothing to say, which the posts reader would drop. */
- data object PostBlank : Refused
-
- /** A kind 31889 the schema reader would refuse, with its reasons. */
- data class SchemaUnusable(val problems: List) : Refused
- }
-
- private const val KEY_KIND = "kind"
- private const val KEY_TAGS = "tags"
- private const val KEY_CONTENT = "content"
-
- /** The paste as something the group could sign, or the reason it is not. */
- fun read(json: String): Reading {
- val element = runCatching { CommonJson.parseToJsonElement(json) }.getOrNull()
- ?: return Refused.NotJson
-
- val obj = element as? JsonObject ?: return Refused.NotAnObject
-
- // A number or a numeric string: relays write the former and a hand
- // types either, and there is nothing to be gained by refusing "1".
- val kind = (obj[KEY_KIND] as? JsonPrimitive)?.intOrNull ?: return Refused.NoKind
- if (kind !in ACCEPTED_KINDS) return Refused.KindNotAccepted(kind)
-
- val tags = when (val raw = obj[KEY_TAGS]) {
- null -> emptyArray()
- else -> readTags(raw) ?: return Refused.TagsMalformed
- }
-
- val content = when (val raw = obj[KEY_CONTENT]) {
- null -> ""
- else -> (raw as? JsonPrimitive)?.takeIf { it.isString }?.content
- ?: return Refused.ContentNotText
- }
-
- return when (kind) {
- // An empty kind 0 is a profile the group has wiped, which it is
- // entitled to do; content that is not JSON is not a profile at all.
- // The same line `GroupNostrProfile.of` draws.
- MetadataEvent.KIND -> if (metadataOf(tags, content) == null) {
- Refused.ProfileUnreadable
- } else {
- Ready(kind, tags, content)
- }
-
- // The line `AddGroupPostViewModel.proposePost` draws, for the reason
- // it gives: a blank post cannot be told from a broken one and, kind:1
- // not being replaceable, cannot be un-said.
- TextNoteEvent.KIND -> if (content.isBlank()) {
- Refused.PostBlank
- } else {
- Ready(kind, tags, content)
- }
-
- CuratedSchemaEvent.KIND -> CuratedSchemaEvent.read(tags, content).problems().let { problems ->
- if (problems.isEmpty()) Ready(kind, tags, content) else Refused.SchemaUnusable(problems)
- }
-
- // A relay list with no relay in it is a list the group has withdrawn,
- // which the relays card shows as "no relays" rather than as nothing.
- else -> Ready(kind, tags, content)
- }
- }
-
- private fun readTags(raw: JsonElement): Array>? {
- val outer = raw as? JsonArray ?: return null
-
- return outer.map { tag ->
- val inner = tag as? JsonArray ?: return null
- inner.map { value ->
- (value as? JsonPrimitive)?.takeIf { it.isString }?.contentOrNull ?: return null
- }.toTypedArray()
- }.toTypedArray()
- }
-
- private fun metadataOf(tags: Array>, content: String) = MetadataEvent(
- id = "",
- pubKey = "",
- createdAt = 0,
- tags = tags,
- content = content,
- sig = "",
- ).contactMetaData()
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupNostrProfile.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupNostrProfile.kt
deleted file mode 100644
index f66c75d7..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupNostrProfile.kt
+++ /dev/null
@@ -1,196 +0,0 @@
-package press.mantra.compose.nostr
-
-import press.mantra.compose.database.model.GroupSignedEvent
-import com.vitorpamplona.quartz.nip01Core.core.HexKey
-import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
-import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
-import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
-import kotlin.time.Instant
-
-/**
- * The kind:0 a group signed about itself, as the group signed it.
- *
- * A Marmot room's id *is* the nostr pubkey it signs as -- see
- * `docs/shared-key-derivation.md`, where those are one value -- so the group has
- * an identity on nostr whether or not it has ever said anything about it. This is
- * what it says: a name, a picture, a line about what the group is for, authored by
- * the group rather than by whichever member typed it.
- *
- * ### Why this reads off [GroupSignedEvent] and not off `Profile`
- *
- * A `Profile` row hangs off a `NostrEvent` by foreign key, and a group-signed
- * event is not a `NostrEvent`: no member sent it, it never travelled on the wire
- * as itself, and the outbound pipeline would re-author it as its sender and strip
- * the group's signature off. See `GroupSignedEvent`, which exists for exactly the
- * events in that position. So the group's profile lives where the group's other
- * signed statements live, and this is the reading of it.
- *
- * The consequence worth stating: **nothing here reaches a relay by being signed.**
- * A member's kind:0 is published and is how the rest of nostr learns their name; a
- * group's is signed and kept, and every device that followed the signing session
- * -- or was handed the event afterwards -- holds it. It reaches a relay when a
- * member sends it there from `BroadcastGroupSignedEventScreen`, which serialises
- * the signed row directly rather than making a `NostrEvent` row for an event no
- * member authored -- a decision this still does not make.
- *
- * ### What a reading has to earn
- *
- * [GroupSignedEvent.verifies] and nothing less: the author has to be the room, the
- * id has to be the hash of the fields beside it, and the signature has to verify.
- * That is what makes this the *group's* profile rather than a kind:0 that happened
- * to be filed against the room -- and it is checkable from the row alone, with no
- * ceremony or key state to consult first.
- *
- * A room never derived from its group's key signs as the bare threshold key rather
- * than as its own id, so it has no profile *for its own pubkey* and gets none
- * here. That is the derivation's limit rather than this reader's, and it is the
- * same limit `GroupSignedEvent.verifies` documents.
- */
-data class GroupNostrProfile(
- /** The group's statement, whole, with the signature that makes it one. */
- val signedEvent: GroupSignedEvent,
- /** Its content parsed: the fields nostr clients read a profile out of. */
- val metadata: UserMetadata,
-) {
- /** The group's nostr identity, which for a derived room is also its id. */
- val publicKey: HexKey get() = signedEvent.publicKey
-
- /** When the group signed it, which is what decides the newest of two. */
- val signedAt: Instant get() = signedEvent.createdAt
-
- /**
- * What to call the group, or null when it has published no name.
- *
- * `display_name` before `name`, which is what `UserMetadata.anyName` does and
- * what every other nostr client does. Null rather than the pubkey: a caller
- * showing this beside the pubkey would otherwise show it twice.
- */
- fun name(): String? = metadata.anyName()?.takeIf { it.isNotBlank() }
-
- fun about(): String? = metadata.about?.takeIf { it.isNotBlank() }
-
- fun picture(): String? = metadata.picture?.takeIf { it.isNotBlank() }
-
- fun banner(): String? = metadata.banner?.takeIf { it.isNotBlank() }
-
- fun website(): String? = metadata.website?.takeIf { it.isNotBlank() }
-
- fun nip05(): String? = metadata.nip05?.takeIf { it.isNotBlank() }
-
- fun lud16(): String? = metadata.lud16?.takeIf { it.isNotBlank() }
-
- /** The event as the group signed it, for [MetadataEvent.updateFromPast]. */
- fun asMetadataEvent(): MetadataEvent = signedEvent.toEvent().let { event ->
- MetadataEvent(
- id = event.id,
- pubKey = event.pubKey,
- createdAt = event.createdAt,
- tags = event.tags,
- content = event.content,
- sig = event.sig,
- )
- }
-
- companion object {
- /**
- * The reading of one signed event, or null when it is not one of these.
- *
- * Three ways to be null and they are all the same refusal: the wrong kind,
- * a signature that does not check out as [chatRoomId]'s, or content that
- * will not parse as a profile. A caller gets a profile the group really
- * signed or gets nothing.
- */
- fun of(signedEvent: GroupSignedEvent, chatRoomId: String): GroupNostrProfile? {
- if (signedEvent.kind != MetadataEvent.KIND) return null
- if (!signedEvent.verifies()) return null
-
- // An empty kind:0 parses to an empty profile rather than to null --
- // wiping a profile is a thing groups are entitled to do, and quartz
- // reads it as the blank one it is. Null here is a parse failure.
- val metadata = MetadataEvent(
- id = signedEvent.id,
- pubKey = signedEvent.publicKey,
- createdAt = signedEvent.createdAt.epochSeconds,
- tags = signedEvent.tags,
- content = signedEvent.content,
- sig = signedEvent.signature,
- ).contactMetaData() ?: return null
-
- return GroupNostrProfile(signedEvent = signedEvent, metadata = metadata)
- }
-
- /**
- * The newest profile [chatRoomId] signed among [signedEvents], or null if
- * it signed none.
- *
- * Newest by the timestamp the group signed at, with the id breaking a tie,
- * because kind:0 is replaceable: a group that edits its profile signs a
- * second one and the second is the answer. Two devices reading the same
- * events in whatever order the queries hand them over have to agree on
- * which, and a tie on the second is not rare enough to leave to luck.
- *
- * Takes the events rather than fetching them so the same question can be
- * asked of a query's result or of a flow's emission.
- */
- fun newestAmong(
- signedEvents: List,
- chatRoomId: String,
- ): GroupNostrProfile? =
- signedEvents
- .asSequence()
- .mapNotNull { of(it, chatRoomId) }
- .maxWithOrNull(
- compareBy({ it.signedAt }, { it.signedEvent.id })
- )
-
- /**
- * The event to ask the group to sign for a profile with these fields.
- *
- * Built from [latest] where the group has one, so a field this app does not
- * offer -- a birthday, a CLINK offer, whatever a future NIP adds -- survives
- * an edit instead of being dropped by it. That is `updateFromPast`'s whole
- * job, and reaching for `createNew` on a group that already has a profile
- * would quietly wipe every such field.
- *
- * Blank means delete, which is `MetadataEvent`'s own rule for these
- * arguments: an empty string removes the key and null leaves it alone. The
- * screen sends what its fields hold, so clearing one clears it in the
- * profile -- which is the only way to *un*-say something a group has said.
- *
- * [name] is written to both `name` and `display_name`, which are the two
- * fields readers pick a name out of and which disagree at their peril. A
- * group whose `display_name` was set by some other tool and whose `name` was
- * edited here would keep answering to the old one in every client that
- * prefers `display_name` -- an edit that visibly does not take. One field on
- * the form, both keys in the event.
- */
- fun template(
- latest: GroupNostrProfile?,
- name: String,
- about: String,
- picture: String,
- website: String,
- nip05: String,
- lud16: String,
- ): EventTemplate = latest?.let {
- MetadataEvent.updateFromPast(
- latest = it.asMetadataEvent(),
- name = name,
- displayName = name,
- about = about,
- picture = picture,
- website = website,
- nip05 = nip05,
- lnAddress = lud16,
- )
- } ?: MetadataEvent.createNew(
- name = name,
- displayName = name,
- about = about,
- picture = picture,
- website = website,
- nip05 = nip05,
- lnAddress = lud16,
- )
- }
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupPost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupPost.kt
deleted file mode 100644
index adbe327b..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupPost.kt
+++ /dev/null
@@ -1,121 +0,0 @@
-package press.mantra.compose.nostr
-
-import press.mantra.compose.database.model.GroupSignedEvent
-import com.vitorpamplona.quartz.nip01Core.core.HexKey
-import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
-import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
-import kotlin.time.Instant
-
-/**
- * A kind:1 the group signed: something it said publicly, as itself.
- *
- * The room's id is the pubkey it signs as, so a note authored by that key is the
- * *group* posting rather than a member posting in the group. Those are different
- * things and the room already has the second one -- the transcript. This is the
- * first: a statement the group put a quorum's signature to, which any reader can
- * check came from the group and not from whoever typed it.
- *
- * ### Why this is not a `Post` row
- *
- * `Post` hangs off both `NostrEvent` and `Profile` by foreign key, and a group has
- * neither: a group-signed event is not a `NostrEvent` -- see `GroupNostrProfile`,
- * which gives the reasoning at length -- and the group has no `Profile` row for the
- * same reason. So a group's posts live where the group's other signed statements
- * live, and this is the reading of them.
- *
- * ### Nothing here has reached a relay
- *
- * The same caveat `GroupNostrProfile` and `GroupRelayList` carry, and it bites
- * hardest here: a post is the one kind of statement whose entire purpose is to be
- * read by people outside the group. `FrostSigningManager.complete` applies signed
- * events locally and puts nothing on the wire, so for now a group's posts are
- * visible to its members and to nobody else. The relay lists beside them say where
- * they should go; sending them is a job that does not exist yet.
- */
-data class GroupPost(
- /** The group's statement, whole, with the signature that makes it one. */
- val signedEvent: GroupSignedEvent,
-) {
- /** The group's nostr identity, which for a derived room is also its id. */
- val publicKey: HexKey get() = signedEvent.publicKey
-
- /** When the group signed it, which is the order posts are read in. */
- val postedAt: Instant get() = signedEvent.createdAt
-
- /** What the group said. */
- val content: String get() = signedEvent.content
-
- /**
- * Whether this is the group speaking rather than answering.
- *
- * A kind:1 with no `e` tag starts a thread; one with them is a reply. Nothing
- * here writes a reply yet -- the composer builds a bare note -- so this exists
- * to keep an imported or chronicled reply from being drawn as an announcement.
- */
- fun isNewThread(): Boolean = asTextNoteEvent().isNewThread()
-
- /** The event as the group signed it. */
- fun asTextNoteEvent(): TextNoteEvent = signedEvent.toEvent().let { event ->
- TextNoteEvent(
- id = event.id,
- pubKey = event.pubKey,
- createdAt = event.createdAt,
- tags = event.tags,
- content = event.content,
- sig = event.sig,
- )
- }
-
- companion object {
- /**
- * The reading of one signed event, or null when it is not one of these.
- *
- * The same gate the profile and the relay lists use: the wrong kind, or a
- * signature that does not check out as [chatRoomId]'s, and a caller gets
- * nothing. A kind:1 a member sent into the room is that member's note, not
- * the group's, and fails on the author.
- */
- fun of(signedEvent: GroupSignedEvent, chatRoomId: String): GroupPost? {
- if (signedEvent.kind != TextNoteEvent.KIND) return null
- if (!signedEvent.verifies()) return null
-
- return GroupPost(signedEvent)
- }
-
- /**
- * Every post [chatRoomId] signed among [signedEvents], newest first.
- *
- * Newest first because that is how a feed is read, and the id breaks a tie
- * so two devices holding the same posts show them in the same order. Unlike
- * a profile or a relay list these accumulate rather than replace: kind:1 is
- * not replaceable, so a second post stands beside the first.
- *
- * Blank posts are dropped. A signed note with nothing in it renders as an
- * empty card that cannot be told from a broken one, and the composer will
- * not produce one -- so anything that reaches here empty came from
- * somewhere this app does not control.
- */
- fun newestFirstAmong(
- signedEvents: List,
- chatRoomId: String,
- ): List =
- signedEvents
- .mapNotNull { of(it, chatRoomId) }
- .filter { it.content.isNotBlank() }
- .sortedWith(compareByDescending { it.postedAt }.thenByDescending {
- it.signedEvent.id
- })
-
- /**
- * The event to ask the group to sign for a post saying [note].
- *
- * A bare kind:1: no reply tags, no mentions, no subject. What the group is
- * doing here is speaking, and every tag `TextNoteEvent.build` can add
- * describes a relationship to something else -- a thread to reply into, a
- * pubkey to notify -- which a group's first way of posting does not have
- * and should not guess at.
- */
- fun template(note: String): EventTemplate =
- TextNoteEvent.build(note = note.trim())
- }
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupRelayList.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupRelayList.kt
deleted file mode 100644
index 9ddbbfe3..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupRelayList.kt
+++ /dev/null
@@ -1,279 +0,0 @@
-package press.mantra.compose.nostr
-
-import press.mantra.compose.database.model.GroupSignedEvent
-import com.vitorpamplona.quartz.nip01Core.core.Kind
-import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
-import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
-import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
-import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost
-import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
-import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
-import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
-import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
-import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
-import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo
-import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType
-import kotlin.time.Instant
-import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.RelayTag as Nip51RelayTag
-import com.vitorpamplona.quartz.nip17Dm.settings.tags.RelayTag as Nip17RelayTag
-
-/**
- * Where a group lives on nostr, as the group itself has said.
- *
- * A Marmot room's id is the pubkey it signs as, so the group is an author like any
- * other and the same four relay lists apply to it: where it publishes and is read
- * from, where its messages arrive, where it can be searched for, and what it will
- * not talk to. Each is a replaceable event of its own kind, so each is agreed --
- * and can be missing -- separately.
- *
- * Read off [GroupSignedEvent] and gated on [GroupSignedEvent.verifies], for the
- * reasons `GroupNostrProfile` gives at length: a group-signed event is not a
- * `NostrEvent`, and only an event the room itself authored is the group speaking.
- *
- * ### Nothing here reaches a relay by being signed
- *
- * The lists say where the group's work *should* go; sending it there is a separate
- * act, taken by a member one event at a time from `BroadcastGroupSignedEventScreen`,
- * and the General list is what that screen fills its relay picker from. See
- * `GroupNostrProfile`, which carries the same caveat for the same reason.
- *
- * ### Public tags only, and not by preference
- *
- * NIP-51 puts a relay list's entries in the encrypted half by default -- a blocked
- * list especially, since who you refuse to talk to is nobody's business. A group
- * cannot use it. The encryption is NIP-44 to the author's own key, and there is no
- * ECDH for a FROST threshold key here: the group can sign and cannot decrypt. So
- * every list is written in public tags, which is a real difference from what a
- * person's client would write and is worth knowing before blocking anything.
- */
-data class GroupRelayList(
- val set: GroupRelaySet,
- /**
- * The group's statement, or null for a set it has never agreed.
- *
- * Null and an empty [relays] are different states: never said, versus said and
- * said nothing. A group that signs an empty list has withdrawn the one it had.
- */
- val signedEvent: GroupSignedEvent?,
- val relays: List,
-) {
- /** When the group signed it, or null for a set it has never agreed. */
- val signedAt: Instant? get() = signedEvent?.createdAt
-
- /** Whether the group has ever put its key to this set. */
- val isAgreed: Boolean get() = signedEvent != null
-
- companion object {
- /**
- * The reading of one signed event as [set], or null when it is not one.
- *
- * The same three refusals as `GroupNostrProfile.of`: the wrong kind, a
- * signature that is not [chatRoomId]'s, or tags that carry no relay.
- */
- fun of(
- signedEvent: GroupSignedEvent,
- chatRoomId: String,
- set: GroupRelaySet,
- ): GroupRelayList? {
- if (signedEvent.kind != set.kind) return null
- if (!signedEvent.verifies()) return null
-
- return GroupRelayList(
- set = set,
- signedEvent = signedEvent,
- relays = set.parse(signedEvent.tags),
- )
- }
-
- /**
- * The newest list [chatRoomId] signed for [set] among [signedEvents].
- *
- * Never null: a set the group has never agreed reads back as an unsigned,
- * empty list rather than as an absence, so a caller always has a row per
- * set to show and [isAgreed] is what separates the two.
- *
- * Newest by the group's own timestamp with the id breaking a tie, for the
- * reason `GroupNostrProfile.newestAmong` gives: these are replaceable, and
- * two devices reading the same events in different orders have to agree.
- */
- fun newestAmong(
- signedEvents: List,
- chatRoomId: String,
- set: GroupRelaySet,
- ): GroupRelayList =
- signedEvents
- .asSequence()
- .mapNotNull { of(it, chatRoomId, set) }
- .maxWithOrNull(compareBy({ it.signedAt }, { it.signedEvent?.id }))
- ?: GroupRelayList(set = set, signedEvent = null, relays = emptyList())
-
- /** Every set, in the order the editor shows them. */
- fun allAmong(
- signedEvents: List,
- chatRoomId: String,
- ): List =
- GroupRelaySet.entries.map { newestAmong(signedEvents, chatRoomId, it) }
- }
-}
-
-/**
- * One relay in one of a group's lists.
- *
- * [read] and [write] mean something only in [GroupRelaySet.General], which is the
- * one list NIP-65 gives markers to; everywhere else a relay is simply in the list
- * and both are true. They are kept on the shared type rather than split into two
- * so that the editor has one row shape and one add path.
- *
- * **Neither is not a state.** NIP-65 writes a bare `r` tag for both, `read` for
- * read-only and `write` for write-only, and has nothing at all for a relay that is
- * neither -- a relay you will not read from and will not write to is one you have
- * removed. The editor enforces that rather than this dropping it silently.
- */
-data class GroupRelay(
- val url: NormalizedRelayUrl,
- val read: Boolean = true,
- val write: Boolean = true,
-) {
- /** The host, for a list that is read rather than copied. */
- fun displayUrl(): String = url.displayUrl()
-}
-
-/**
- * The relay lists a group keeps, and what each one is for.
- *
- * The four a person's client keeps, because a group is an author like any other and
- * these are the four questions the network asks about one.
- *
- * **Key packages (MIP-00, kind 10051) are deliberately not here**, though this app
- * writes one for every person. A key package is a device's offer to be added to an
- * MLS group, and a group has no device and joins nothing -- so a group advertising
- * where its key packages live would be pointing at a place that will always be
- * empty, which is worse than saying nothing. See `MarmotKeyPackage`, which is keyed
- * by member.
- *
- * **Relay feeds (kind 10012) are not here either**, for the harder reason: the list
- * lives in the encrypted half and a threshold key cannot decrypt. See
- * [GroupRelayList]'s note on public tags.
- */
-enum class GroupRelaySet(
- val kind: Kind,
- /** Whether NIP-65's read and write markers apply, which is General alone. */
- val hasReadWriteMarkers: Boolean = false,
-) {
- /**
- * NIP-65: where the group publishes and where a reader should look for it.
- *
- * The one that matters most and the only one with markers. Everything the group
- * signs is addressed to whoever wants to read it, and this is the answer to
- * where.
- */
- General(AdvertisedRelayListEvent.KIND, hasReadWriteMarkers = true),
-
- /** NIP-17: where a message addressed to the group's key should be sent. */
- Messages(ChatMessageRelayListEvent.KIND),
-
- /** NIP-50: the relays the group would have a search of its work run against. */
- Search(SearchRelayListEvent.KIND),
-
- /** NIP-51: relays the group will not talk to, whatever else says otherwise. */
- Blocked(BlockedRelayListEvent.KIND),
- ;
-
- /**
- * What a group with nothing agreed starts the editor at.
- *
- * The app's own relay, which is where everything else this build publishes and
- * reads already goes -- see `Relays.ephemeral`. A group seeded with nothing
- * would be a group whose first proposal is an empty list, which says less than
- * having never said anything at all.
- *
- * Blocked is the exception and starts empty on purpose: a default there is a
- * refusal to talk to somebody that nobody in the group chose.
- */
- fun defaults(): List = when (this) {
- Blocked -> emptyList()
- else -> listOf(GroupRelay(Relays.ephemeral))
- }
-
- /** The relays out of a signed event's tags, in the order the group wrote them. */
- fun parse(tags: Array>): List = when (this) {
- General -> tags.mapNotNull(AdvertisedRelayInfo::parse).map {
- GroupRelay(
- url = it.relayUrl,
- read = it.type.isRead(),
- write = it.type.isWrite(),
- )
- }
- // Both nip51 lists and NIP-17's use a "relay" tag, and the two RelayTag
- // classes that parse it are different classes in different packages. Named
- // apart at the import so a reader can see which NIP each line is following.
- Messages -> tags.mapNotNull(Nip17RelayTag::parse).map { GroupRelay(it) }
- Search, Blocked -> tags.mapNotNull(Nip51RelayTag::parse).map { GroupRelay(it) }
- }.distinctBy { it.url }
-
- /**
- * The event to ask the group to sign for this set.
- *
- * Built from scratch rather than from the group's last one, which is the
- * opposite of what `GroupNostrProfile.template` does and for a reason: a
- * profile is a bag of independent fields where an unknown one is worth keeping,
- * and a relay list is one list where the whole point of signing a new one is to
- * replace it. Carrying a tag forward here would make a removal impossible.
- *
- * A relay that is neither read nor write is dropped rather than written, since
- * NIP-65 cannot express one -- the editor does not allow the state, and this is
- * the second line of that defence rather than the first.
- */
- fun template(relays: List): EventTemplate<*> {
- val tags = when (this) {
- General -> relays.mapNotNull { relay ->
- val type = when {
- relay.read && relay.write -> AdvertisedRelayType.BOTH
- relay.read -> AdvertisedRelayType.READ
- relay.write -> AdvertisedRelayType.WRITE
- else -> return@mapNotNull null
- }
- AdvertisedRelayInfo.assemble(relay.url, type)
- }
- Messages -> relays.map { Nip17RelayTag.assemble(it.url) }
- Search, Blocked -> relays.map { Nip51RelayTag.assemble(it.url) }
- }
-
- // Empty content throughout. For General and Messages that is all the kind
- // has; for the two nip51 lists it is where the encrypted half would go, and
- // a group has no encrypted half. See [GroupRelayList].
- return EventTemplate(
- createdAt = kotlin.time.Clock.System.now().epochSeconds,
- kind = kind,
- tags = tags.toTypedArray(),
- content = "",
- )
- }
-
- companion object {
- /**
- * The kinds a group's relay lists are written as.
- *
- * A set rather than a walk of [entries], for `ChatMessage.applyInnerEvent`,
- * whose dispatch is one `when` over an event's kind -- and a `when` branch
- * has to be a constant expression rather than a predicate over an enum.
- */
- val KINDS: Set = entries.map { it.kind }.toSet()
-
- /**
- * [url] as a relay a group could actually be told to use, or null.
- *
- * Structural only, and deliberately narrow: `wss://` because a group's
- * signed list is read by strangers over the open network and `ws://` would
- * ask them to fetch it in the clear, and no loopback because a relay only
- * this device can reach is not something to put a quorum's signature on.
- * Wisp's `RelayConfig.isValidUrl` refuses the same three things.
- */
- fun relayUrlOrNull(url: String): NormalizedRelayUrl? {
- val trimmed = url.trim()
- if (!trimmed.startsWith("wss://", ignoreCase = true)) return null
-
- return RelayUrlNormalizer.normalizeOrNull(trimmed)?.takeUnless { it.isLocalHost() }
- }
- }
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEvent.kt
deleted file mode 100644
index 5543e0aa..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEvent.kt
+++ /dev/null
@@ -1,601 +0,0 @@
-package press.mantra.compose.nostr.curated
-
-import com.vitorpamplona.quartz.nip01Core.core.Event
-import com.vitorpamplona.quartz.nip01Core.core.HexKey
-import com.vitorpamplona.quartz.nip01Core.core.Kind
-import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
-import kotlin.time.Clock
-
-/**
- * Kinds 31888 and 31890: an entry in a curated list, as somebody suggested it and
- * as the curator accepted it.
- *
- * The two are one shape. A suggestion is a reply to a schema carrying one tag
- * per field the schema defines; a canonical entry is the same tags again, signed
- * by the curator, with an optional pointer back at the suggestion it came from.
- * They are read by one reader and checked by one verifier, and only the kind,
- * the signer and the pointer tell them apart -- which is what lets the list
- * stand on its own if a suggester deletes their entry.
- *
- * This is the other half of [CuratedSchemaEvent]: that is what a list *asks*
- * for, this is what it *gets*. Ported from the reference module's `verifyEntry`
- * rule for rule, so that an entry bitcoin.mov accepts is one this app accepts
- * and an entry it refuses is refused here for the same reason. The write side,
- * [canonicalTemplate], is the port of its `buildCuratedCanonicalTemplate`, and
- * walks the same field list as [read] so the two cannot drift.
- *
- * ### Rejected, not repaired
- *
- * The NIP is explicit: a client MUST verify an entry against its schema and
- * MUST reject one that does not satisfy it, rather than showing it with the
- * offending parts blanked out. Relays carry malformed, partial and hostile
- * events from other applications, and a half-parsed entry is worse than a
- * missing one. So [suggestion] and [canonical] return the entry whole or return
- * null, and [verifySuggestion] and [verifyCanonical] say why.
- */
-object CuratedEntryEvent {
- /** The kind anyone publishes a suggestion under. */
- const val SUGGESTION_KIND: Kind = CuratedSchemaEvent.SUGGESTION_KIND
-
- /** The kind the curator republishes an accepted suggestion as. */
- const val CANONICAL_KIND: Kind = CuratedSchemaEvent.CANONICAL_KIND
-
- private const val TAG_ADDRESS = "a"
- private const val TAG_EVENT = "e"
- private const val TAG_IDENTIFIER = "d"
- private const val TAG_TITLE = "title"
- private const val TAG_PUBKEY = "p"
- private const val TAG_KIND = "k"
-
- /** NIP-10's markers: the schema being replied to, and the suggestion being credited. */
- private const val MARKER_ROOT = "root"
- private const val MARKER_MENTION = "mention"
-
- /** What every schema coordinate starts with, which is how a root is told from a source. */
- private val SCHEMA_PREFIX = "${CuratedSchemaEvent.KIND}:"
-
- private val SUGGESTION_PREFIX = "$SUGGESTION_KIND:"
-
- /**
- * The entry [event] carries, checked against [schema] as a suggestion to the
- * list at [coordinate], or null when it is not one a client should show.
- */
- fun suggestion(event: Event, schema: CuratedSchema, coordinate: String): CuratedEntry? =
- if (verifySuggestion(event, schema, coordinate).isEmpty()) read(event, schema) else null
-
- /**
- * The entry [event] carries, checked against [schema] as the list's own at
- * [coordinate], or null. [curator] is the pubkey that signed the schema, and
- * the only one this will accept an author of.
- */
- fun canonical(event: Event, schema: CuratedSchema, coordinate: String, curator: HexKey): CuratedEntry? =
- if (verifyCanonical(event, schema, coordinate, curator).isEmpty()) read(event, schema) else null
-
- /**
- * The entry as the tags say it, whether or not it satisfies the schema.
- *
- * Split from [suggestion] and [canonical] for the same reason
- * [CuratedSchemaEvent.read] is split from its parse: a reader that wants to
- * show *why* -- a test, a form -- verifies separately. Everything here is
- * keyed by the schema's field names rather than by tag, because a field is
- * what a form and a screen know an entry by; the tag is where it was kept.
- */
- fun read(event: Event, schema: CuratedSchema): CuratedEntry {
- val values = schema.fields
- .associate { field -> field.name to valuesOf(field, event.tags, event.content) }
- .filterValues { it.isNotEmpty() }
-
- return CuratedEntry(
- kind = event.kind,
- id = event.id,
- pubKey = event.pubKey,
- createdAt = event.createdAt,
- identifier = first(event.tags, TAG_IDENTIFIER)?.trim() ?: "",
- title = first(event.tags, TAG_TITLE)?.trim() ?: "",
- values = values,
- source = if (event.kind == CANONICAL_KIND) source(event.tags) else null,
- )
- }
-
- /**
- * The parts of an entry that need no schema to read: its identifier, its
- * title and, for a canonical entry, where it came from.
- *
- * For a reader that has the event and not the list -- the signing screen,
- * the transcript -- and wants to say which entry this is rather than check
- * it. [CuratedEntry.values] is empty, since without a schema there are no
- * fields to key them by.
- */
- fun headline(event: Event): CuratedEntry = read(event, NO_SCHEMA)
-
- /** A schema with no fields, for [headline]: the two lifted tags are read regardless. */
- private val NO_SCHEMA = CuratedSchema(identifier = "", title = "", name = "", description = "", visibility = null)
-
- /**
- * Every reason [event] is not a suggestion to the list at [coordinate], or
- * nothing.
- *
- * The NIP's seven rules in the NIP's order: the kind is the suggestion kind;
- * every required field is present; no field without `repeat` appears twice;
- * every value matches its field's type and config; every `require-any` group
- * is met; the `a` root names the schema; and the author is somebody the
- * schema's visibility lets suggest.
- */
- fun verifySuggestion(event: Event, schema: CuratedSchema, coordinate: String): List =
- verifyEntry(event, schema, coordinate, expectedKind = schema.suggestionKind)
-
- /**
- * Every reason [event] is not the list's own entry at [coordinate], or nothing.
- *
- * Everything [verifySuggestion] checks, for kind 31890, and two rules on top.
- * The author must be [curator]: curation is the one power a schema does not
- * delegate, and `visibility` has no bearing on it. And a source reference,
- * when there is one, must be a well-formed suggestion coordinate -- a
- * malformed one is worse than none, since it credits the wrong person or
- * points at an entry nobody suggested.
- */
- fun verifyCanonical(
- event: Event,
- schema: CuratedSchema,
- coordinate: String,
- curator: HexKey,
- ): List = buildList {
- addAll(verifyEntry(event, schema, coordinate, expectedKind = CANONICAL_KIND))
-
- if (event.pubKey != curator) {
- add(CuratedEntryProblem(FIELD_CURATOR, CuratedEntryProblem.Reason.NotTheCurator))
- }
-
- val source = firstAddress(event.tags, SUGGESTION_PREFIX)
- if (source != null && CuratedCoordinate.parse(source) == null) {
- add(CuratedEntryProblem(FIELD_SOURCE, CuratedEntryProblem.Reason.SourceMalformed))
- }
- }
-
- /**
- * The unsigned canonical entry for the curator to sign: [values] laid out as
- * the schema's tags, rooted at the schema's [coordinate], and pointing back
- * at [source] when it came from a suggestion.
- *
- * The reverse of [read], and the reason a canonical entry and the suggestion
- * it came from are one shape: both walk the schema's one field list, so what
- * this writes is what [canonical] reads back, field for field. Ported from
- * the reference module's `buildCuratedCanonicalTemplate` tag for tag, so
- * that an entry this app signs is one bitcoin.mov's list shows.
- *
- * [values] is keyed by field name the way [CuratedEntry.values] is, derived
- * fields included. The reference client derives `d` and `t` for a suggestion
- * it is composing; this app composes none, and a suggestion it accepts keeps
- * what the suggester's client derived -- the `d` above all, which the NIP
- * says a canonical entry SHOULD keep so that re-curating revises the entry
- * in place rather than adding a second one. Nothing is clipped or repaired
- * on the way through: a value the schema refuses is refused by
- * [verifyCanonical] on the result, with the field named, which is what a
- * form wants to show. A blank value is not a value and writes nothing, and a
- * field without `repeat` writes its first value only.
- *
- * [relayHint] goes on the root, per NIP-10: where a reader who does not know
- * the list can find the schema being replied to. The NIP says it SHOULD be
- * the first of the schema's relays, and by default it is.
- */
- fun canonicalTemplate(
- schema: CuratedSchema,
- coordinate: String,
- curator: HexKey,
- values: Map>,
- source: CuratedSuggestionRef? = null,
- relayHint: String? = schema.relays.firstOrNull(),
- createdAt: Long = Clock.System.now().epochSeconds,
- ): EventTemplate {
- val (fieldTags, content) = fieldTags(schema, values)
-
- return EventTemplate(
- createdAt = createdAt,
- kind = CANONICAL_KIND,
- tags = (fieldTags + replyTags(coordinate, curator, relayHint) + sourceTags(source)).toTypedArray(),
- content = content,
- )
- }
-
- /**
- * [template] as an event the verifiers can read: the kind, the tags, the
- * content and [pubKey], which is everything they look at, over a blank id
- * and signature, which they do not. For checking what is about to be
- * proposed before a quorum is spent on it.
- */
- fun preview(template: EventTemplate<*>, pubKey: HexKey): Event = Event(
- id = "",
- pubKey = pubKey,
- createdAt = template.createdAt,
- kind = template.kind,
- tags = template.tags,
- content = template.content,
- sig = "",
- )
-
- /**
- * The tags that make an entry a reply to its schema, and nothing else.
- *
- * The root is the schema's coordinate rather than an event id, because the
- * kind is addressable: revising the schema mints a new id and keeps the
- * coordinate, and an entry pinned to an id would be orphaned by the first
- * edit. `p` and `k` are the reply courtesies the NIP asks for and nothing
- * verifies; `a` is what is verified and what relays are queried on.
- */
- fun replyTags(coordinate: String, curator: HexKey, relayHint: String?): List> = listOf(
- arrayOf(TAG_ADDRESS, coordinate, relayHint ?: "", MARKER_ROOT),
- arrayOf(TAG_PUBKEY, curator),
- arrayOf(TAG_KIND, CuratedSchemaEvent.KIND.toString()),
- )
-
- /**
- * The values as the schema's tags: one per value at the field's tag, with
- * its marker as the third element when it has one, or the body for the
- * field that writes there.
- */
- private fun fieldTags(schema: CuratedSchema, values: Map>): Pair>, String> {
- val tags = mutableListOf>()
- var content = ""
-
- for (field in schema.fields) {
- val tag = field.tag()
- val marker = field.config.marker
-
- for (value in values[field.name].orEmpty()) {
- val trimmed = value.trim()
- if (trimmed.isEmpty()) continue
-
- if (tag == CuratedFieldConfig.CONTENT_TAG) {
- content = trimmed
- } else {
- tags.add(if (marker != null) arrayOf(tag, trimmed, marker) else arrayOf(tag, trimmed))
- }
-
- if (field.config.repeat != true) break
- }
- }
-
- return tags to content
- }
-
- /**
- * Both halves of the pointer back: the coordinate, which follows the
- * suggester's later edits, and the id, which pins what the curator actually
- * read. They answer different questions, which is why the NIP has both. The
- * `p` credits the suggester.
- */
- private fun sourceTags(source: CuratedSuggestionRef?): List> {
- if (source == null) return emptyList()
-
- return buildList {
- add(arrayOf(TAG_ADDRESS, source.coordinate, "", MARKER_MENTION))
- source.id?.let { add(arrayOf(TAG_EVENT, it, "", MARKER_MENTION)) }
- add(arrayOf(TAG_PUBKEY, source.pubKey))
- }
- }
-
- /**
- * One value against its field's type and config, or the first thing wrong
- * with it.
- *
- * `max` is a bound on numeric types and a length on everything else, and a
- * numeric field returns before the length check so the two never meet.
- * A pattern the platform cannot compile counts as not matched: the schema
- * asked for a check this reader cannot make, and an entry it cannot check
- * is one it cannot vouch for.
- */
- fun checkValue(field: CuratedField, value: String): CuratedEntryProblem.Reason? {
- val trimmed = value.trim()
- val config = field.config
-
- if (trimmed.isEmpty()) return CuratedEntryProblem.Reason.Empty
-
- when (field.type) {
- CuratedFieldType.Text, CuratedFieldType.LongText -> Unit
-
- CuratedFieldType.Token ->
- if (trimmed.any { it.isWhitespace() }) return CuratedEntryProblem.Reason.NotOneWord
-
- CuratedFieldType.Url ->
- if (!isSafeUrl(trimmed, httpsOnly = config.https == true)) {
- return if (config.https == true) {
- CuratedEntryProblem.Reason.NotHttps
- } else {
- CuratedEntryProblem.Reason.NotAUrl
- }
- }
-
- CuratedFieldType.Image ->
- if (!isSafeUrl(trimmed, httpsOnly = true)) return CuratedEntryProblem.Reason.NotHttps
-
- CuratedFieldType.Enum ->
- if (config.options?.contains(trimmed) != true) return CuratedEntryProblem.Reason.NotAnOption
-
- CuratedFieldType.Year, CuratedFieldType.Duration, CuratedFieldType.Number -> {
- val number = trimmed.toWholeNumberOrNull() ?: return CuratedEntryProblem.Reason.NotAWholeNumber
- if (field.type == CuratedFieldType.Duration && number <= 0) return CuratedEntryProblem.Reason.NotPositive
- config.min?.let { if (number < it) return CuratedEntryProblem.Reason.BelowMinimum }
- config.max?.let { if (number > it) return CuratedEntryProblem.Reason.AboveMaximum }
- // A numeric `max` is a bound, not a length cap.
- return null
- }
- }
-
- config.max?.let { if (trimmed.length > it) return CuratedEntryProblem.Reason.TooLong }
-
- config.pattern?.let { pattern ->
- val whole = runCatching { Regex("^(?:$pattern)$") }.getOrNull()
- if (whole == null || !whole.matches(trimmed)) return CuratedEntryProblem.Reason.PatternMismatch
- }
-
- return null
- }
-
- /**
- * Every value [field] holds in an event: its tags, or the content body.
- *
- * A field with a marker matches only tags carrying that marker in third
- * position; one without matches every tag of its name, whatever the third
- * element says. Blank values are not values.
- */
- fun valuesOf(field: CuratedField, tags: Array>, content: String): List {
- val tag = field.tag()
- if (tag == CuratedFieldConfig.CONTENT_TAG) {
- return content.trim().takeIf { it.isNotEmpty() }?.let { listOf(it) } ?: emptyList()
- }
-
- val marker = field.config.marker
- return tags
- .filter { it.size > 1 && it[0] == tag && it[1].isNotBlank() }
- .filter { marker == null || it.getOrNull(2) == marker }
- .map { it[1] }
- }
-
- /**
- * Where a canonical entry says it came from, or null for one nobody suggested.
- *
- * The coordinate is the `a` tag with the suggestion kind's prefix, which is
- * what tells it from the schema root; the id is the first `e` tag, pinning
- * the exact version the curator reviewed, and may be absent.
- */
- fun source(tags: Array>): CuratedSuggestionRef? {
- val address = firstAddress(tags, SUGGESTION_PREFIX) ?: return null
- val coordinate = CuratedCoordinate.parse(address) ?: return null
-
- return CuratedSuggestionRef(
- id = first(tags, TAG_EVENT)?.trim()?.ifEmpty { null },
- coordinate = address,
- pubKey = coordinate.pubKey,
- )
- }
-
- /** An http or https URL with a host, or with [httpsOnly] an https one. Nothing else reaches a link. */
- fun isSafeUrl(value: String, httpsOnly: Boolean = false): Boolean =
- if (httpsOnly) CuratedSchemaEvent.isHttpsUrl(value) else HTTP_URL.matches(value.trim())
-
- private val HTTP_URL = Regex("^https?://[^\\s/?#]+[^\\s]*$", RegexOption.IGNORE_CASE)
-
- /** The problems a suggestion and a canonical entry share. */
- private fun verifyEntry(
- event: Event,
- schema: CuratedSchema,
- coordinate: String,
- expectedKind: Kind,
- ): List = buildList {
- if (event.kind != expectedKind) {
- add(CuratedEntryProblem(FIELD_KIND, CuratedEntryProblem.Reason.WrongKind))
- }
-
- // The root is required whenever the schema has a coordinate to reply to,
- // which here is always: a group's schema is signed before anything can
- // reply to it. Only schema coordinates count; an entry carries `a` tags
- // for other reasons too, the source pointer among them.
- val roots = event.tags
- .filter { it.size > 1 && it[0] == TAG_ADDRESS && it[1].startsWith(SCHEMA_PREFIX) }
- .map { it[1] }
- if (roots.isEmpty()) {
- add(CuratedEntryProblem(FIELD_SCHEMA, CuratedEntryProblem.Reason.NotAReply))
- } else if (coordinate !in roots) {
- add(CuratedEntryProblem(FIELD_SCHEMA, CuratedEntryProblem.Reason.RepliesElsewhere))
- }
-
- for (field in schema.fields) {
- val values = valuesOf(field, event.tags, event.content)
-
- if (values.isEmpty()) {
- if (field.isRequired) add(CuratedEntryProblem(field.name, CuratedEntryProblem.Reason.Missing))
- continue
- }
-
- if (values.size > 1 && field.config.repeat != true) {
- add(CuratedEntryProblem(field.name, CuratedEntryProblem.Reason.Repeated))
- }
-
- // One problem per field is enough.
- values.firstNotNullOfOrNull { checkValue(field, it) }
- ?.let { add(CuratedEntryProblem(field.name, it)) }
- }
-
- for (group in schema.requireAny) {
- val satisfied = group.any { name ->
- schema.fields.firstOrNull { it.name == name }
- ?.let { valuesOf(it, event.tags, event.content).isNotEmpty() }
- ?: false
- }
- if (!satisfied) {
- add(CuratedEntryProblem(group.first(), CuratedEntryProblem.Reason.RequireAnyUnmet))
- }
- }
-
- if (!schema.canSuggest(event.pubKey, coordinate)) {
- add(CuratedEntryProblem(FIELD_VISIBILITY, CuratedEntryProblem.Reason.NotPermitted))
- }
- }
-
- /**
- * Whether [pubKey] may suggest to this list, at [coordinate].
- *
- * Everyone on a public list; the curator and the `p` tags on the other two.
- * The curator is read off the coordinate rather than passed, because a
- * [CuratedSchema] does not know who signed it and the coordinate does.
- */
- private fun CuratedSchema.canSuggest(pubKey: HexKey, coordinate: String): Boolean {
- if (visibility == CuratedVisibility.Public) return true
- if (CuratedCoordinate.parse(coordinate)?.pubKey == pubKey) return true
- return pubKey in suggesters
- }
-
- private fun first(tags: Array>, name: String): String? =
- tags.firstOrNull { it.size > 1 && it[0] == name }?.get(1)
-
- private fun firstAddress(tags: Array>, prefix: String): String? =
- tags.firstOrNull { it.size > 1 && it[0] == TAG_ADDRESS && it[1].startsWith(prefix) }?.get(1)
-
- /** An optionally signed run of digits and nothing else, as the reference reads one. */
- private fun String.toWholeNumberOrNull(): Long? =
- takeIf { WHOLE_NUMBER.matches(it) }?.toLongOrNull()
-
- private val WHOLE_NUMBER = Regex("^-?\\d+$")
-
- /** The names problems are filed under when they are about the entry rather than a field. */
- const val FIELD_KIND = "kind"
- const val FIELD_SCHEMA = "schema"
- const val FIELD_VISIBILITY = "visibility"
- const val FIELD_CURATOR = "curator"
- const val FIELD_SOURCE = "source"
-}
-
-/**
- * One entry in a curated list, read off a kind 31888 or 31890 event.
- *
- * [values] is keyed by the schema's field names, each holding every value the
- * entry carries for that field in tag order -- more than one only for a field
- * the schema lets repeat. A field the entry has nothing for is absent rather
- * than empty. [identifier] and [title] are also in there under whatever the
- * schema calls those fields; they are lifted out because every list has them
- * and every screen wants them.
- */
-data class CuratedEntry(
- val kind: Kind,
- val id: HexKey,
- val pubKey: HexKey,
- /** Unix seconds, as the event carries it. */
- val createdAt: Long,
- /** The `d` tag, which with [kind] and [pubKey] is the entry's coordinate. */
- val identifier: String,
- val title: String,
- val values: Map>,
- /** For a canonical entry, the suggestion it came from, or null when nobody suggested it. */
- val source: CuratedSuggestionRef? = null,
-) {
- val isCanonical: Boolean get() = kind == CuratedEntryEvent.CANONICAL_KIND
-
- /** `kind:pubkey:d`. Two events sharing one are versions of one entry. */
- fun coordinate(): String = CuratedCoordinate(kind, pubKey, identifier).toValue()
-
- /** The first value of [fieldName], or null when the entry has none. */
- fun value(fieldName: String): String? = values[fieldName]?.firstOrNull()
-}
-
-/** Where a canonical entry came from: the suggestion the curator accepted. */
-data class CuratedSuggestionRef(
- /** The exact version curated, or null when the curator did not pin one. */
- val id: HexKey?,
- /** `31888::`, which follows the suggester's later edits. */
- val coordinate: String,
- /** The suggester, credited. */
- val pubKey: HexKey,
-) {
- companion object {
- /**
- * The pointer a canonical entry made from [entry] carries: its id, its
- * coordinate and its author. Only a suggestion can be pointed at -- a
- * canonical entry is the list's own word, and the list did not suggest
- * anything to itself.
- */
- fun of(entry: CuratedEntry): CuratedSuggestionRef {
- require(!entry.isCanonical) { "A canonical entry is not the source of another" }
-
- return CuratedSuggestionRef(
- id = entry.id,
- coordinate = entry.coordinate(),
- pubKey = entry.pubKey,
- )
- }
- }
-}
-
-/**
- * One reason an entry does not satisfy its schema: which field, and what about
- * it. [field] is the field's name, or one of the [CuratedEntryEvent] `FIELD_`
- * names for a rule about the entry as a whole; a `require-any` group is filed
- * under its first field, so a form can show it inline.
- */
-data class CuratedEntryProblem(val field: String, val reason: Reason) {
- enum class Reason {
- /** Not the kind the schema said suggestions come as, or not the canonical kind. */
- WrongKind,
-
- /** No `a` root naming any schema. */
- NotAReply,
-
- /** An `a` root naming some other schema. */
- RepliesElsewhere,
-
- /** A required field with no value. */
- Missing,
-
- /** A field without `repeat` with more than one. */
- Repeated,
-
- Empty,
- NotOneWord,
- NotAUrl,
- NotHttps,
- NotAnOption,
- NotAWholeNumber,
- NotPositive,
- BelowMinimum,
- AboveMaximum,
- TooLong,
- PatternMismatch,
-
- /** None of a `require-any` group's fields is present. */
- RequireAnyUnmet,
-
- /** The author may not suggest to a closed or private list. */
- NotPermitted,
-
- /** A canonical entry signed by somebody other than the schema's author. */
- NotTheCurator,
-
- /** A source pointer that is not a suggestion coordinate. */
- SourceMalformed,
- }
-}
-
-/**
- * An addressable coordinate, `::`, split the one way the NIP
- * allows: on the first two colons only. A `d` may itself contain colons --
- * `imdb:tt2821314` does -- so splitting on every one mangles the identifier and
- * miscounts the parts.
- */
-data class CuratedCoordinate(val kind: Kind, val pubKey: HexKey, val identifier: String) {
- fun toValue(): String = "$kind:$pubKey:$identifier"
-
- companion object {
- private val SHAPE = Regex("^(\\d{1,5}):([0-9a-fA-F]{64}):(.+)$")
-
- /** The three parts, or null for anything that is not a coordinate with a non-empty `d`. */
- fun parse(value: String): CuratedCoordinate? {
- val match = SHAPE.matchEntire(value.trim()) ?: return null
- val (kind, pubKey, identifier) = match.destructured
-
- return CuratedCoordinate(
- kind = kind.toInt(),
- pubKey = pubKey.lowercase(),
- identifier = identifier,
- )
- }
- }
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedSchema.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedSchema.kt
deleted file mode 100644
index cc6e7d6d..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedSchema.kt
+++ /dev/null
@@ -1,475 +0,0 @@
-package press.mantra.compose.nostr.curated
-
-import com.vitorpamplona.quartz.nip01Core.core.HexKey
-import com.vitorpamplona.quartz.nip01Core.core.Kind
-import kotlinx.serialization.json.JsonArray
-import kotlinx.serialization.json.JsonElement
-import kotlinx.serialization.json.JsonObject
-import kotlinx.serialization.json.JsonPrimitive
-import kotlinx.serialization.json.booleanOrNull
-import kotlinx.serialization.json.buildJsonObject
-import kotlinx.serialization.json.contentOrNull
-import kotlinx.serialization.json.jsonPrimitive
-import kotlinx.serialization.json.longOrNull
-import kotlinx.serialization.json.put
-import press.mantra.compose.network.serialization.CommonJson
-
-/**
- * The definition of a curated list: what an entry may contain, who may suggest
- * one, and whose list it is.
- *
- * This is the document the other two curated kinds are checked against. A
- * suggestion (kind 31888) is a reply to it that has to satisfy its fields; a
- * canonical entry (kind 31890) is a suggestion its curator republished under
- * their own key. The list is the set of canonical entries and the suggestions
- * are its queue -- and this is the form both are filled in against, published
- * so that another client can render the same form without shipping this code.
- *
- * The curator's pubkey is the namespace. `31889::` names
- * one schema and no other, so nothing global is claimed and nothing can be
- * squatted. Here the curator is a group: the schema is signed by the room's own
- * key, like its profile and its posts, and it is the group -- by quorum -- that
- * accepts a suggestion into the list.
- *
- * The protocol is written up in `bitcoin.mov/docs/NIP.md`, and the shape here
- * follows its reference module tag for tag so that a schema this app signs is
- * one that site's form can be driven by, and one it publishes reads here.
- *
- * ### What is checked, and where
- *
- * [problems] is the list of reasons a schema is unusable, and it is the same
- * list whether the schema was typed into the editor or read off a relay: the
- * NIP's rejection rules, no more. A reader rejects rather than repairs --
- * relays carry malformed and hostile events, and a half-parsed form is worse
- * than a missing one -- and the editor refuses to propose what a reader would
- * refuse to show.
- *
- * [normalized] is the one repair the NIP requires. Every list has a field
- * writing to `d` and one writing to `title`, both required, and a schema that
- * omits or relaxes either gets them put back -- so no schema, wherever it came
- * from, can talk a client into accepting untitled or unaddressable entries.
- */
-data class CuratedSchema(
- /** The `d` tag: the schema's identifier, and the last part of its coordinate. */
- val identifier: String,
- /** Labels the event -- "bitcoin.mov suggestion". */
- val title: String,
- /** The list's identity, what people call it -- "bitcoin.mov". */
- val name: String,
- /** What the list is for. Mirrored into the event's content for generic clients. */
- val description: String,
- /**
- * Who may suggest, or null when the schema does not say.
- *
- * Null is never defaulted away: a list that does not say who may suggest to
- * it is not one a client should act on, so an absent or unrecognised
- * visibility is a [CuratedSchemaProblem.VisibilityMissing] rather than a
- * public list by accident. The editor always has one; only a schema read off
- * a relay can be missing it.
- */
- val visibility: CuratedVisibility?,
- /** An https image for the list, or null. */
- val picture: String? = null,
- /**
- * A hostname the list publishes under, or null. Shown in place of [name]
- * wherever the list is named, which is why it is a claim worth being careful
- * with: anyone can put any domain in a tag. See [displayName].
- */
- val domain: String? = null,
- /** The kind suggestions are published under. There is only one. */
- val suggestionKind: Kind = CuratedSchemaEvent.SUGGESTION_KIND,
- val fields: List = emptyList(),
- /**
- * Groups of field names of which at least one must be present on an entry.
- *
- * How a list says "a watch link or an IMDb link" without making either one
- * required. A group of fewer than two names says nothing and is dropped.
- */
- val requireAny: List> = emptyList(),
- /**
- * Pubkeys allowed to suggest on a closed or private list, besides the
- * curator. The `p` tags. Meaningless on a public list, where anyone may.
- */
- val suggesters: List = emptyList(),
- /**
- * Where the list lives: the relays suggestions and canonical entries are
- * published to and read from. Signed into the event so a client that finds
- * the schema anywhere knows where a reply belongs and cannot be sent
- * elsewhere by an unsigned config file.
- */
- val relays: List = emptyList(),
-) {
- /**
- * What to call the list on screen: the domain when it has one, else the
- * name. The domain is the stronger identity because it is the one part of
- * this that can be checked against the outside world, NIP-05 style -- and
- * nothing here does check it, so it is a label rather than a badge.
- */
- fun displayName(): String = domain ?: name
-
- /** Fields a form should prompt for. Derived ones are filled in by the client. */
- fun formFields(): List = fields.filterNot { it.config.derived == true }
-
- /**
- * The same schema with the two mandatory fields forced into place.
- *
- * A field writing to `d` and one writing to `title`, both required, in that
- * order at the front when they were missing. A schema that already has them
- * is unchanged apart from the requirement, so running this twice is running
- * it once.
- */
- fun normalized(): CuratedSchema {
- val fields = fields.toMutableList()
-
- for ((tag, fallback) in CuratedField.MANDATORY) {
- val index = fields.indexOfFirst { it.tag() == tag }
- if (index == -1) {
- fields.add(0, fallback)
- } else if (!fields[index].isRequired) {
- fields[index] = fields[index].copy(isRequired = true)
- }
- }
-
- return copy(fields = fields)
- }
-
- /**
- * Every reason this is not a usable schema, or nothing.
- *
- * The NIP's rejection rules and only those: a client MUST refuse a schema
- * missing its identifier, title, name, description or visibility, one whose
- * visibility is not one of the three words, one with no fields, and one
- * whose picture is not https, whose domain is not a hostname, or whose relay
- * is not a relay. The length caps are the reference module's, so a schema
- * this app signs is one that site accepts.
- *
- * Checked on the schema as written, before [normalized] -- "no fields" is
- * about what the publisher said, not about what a reader would put back.
- */
- fun problems(): List = buildList {
- fun text(value: String, cap: Int, missing: CuratedSchemaProblem, tooLong: CuratedSchemaProblem) {
- if (value.isBlank()) add(missing) else if (value.length > cap) add(tooLong)
- }
-
- text(identifier, CuratedSchemaEvent.MAX_IDENTIFIER, CuratedSchemaProblem.IdentifierMissing, CuratedSchemaProblem.IdentifierTooLong)
- text(title, CuratedSchemaEvent.MAX_TITLE, CuratedSchemaProblem.TitleMissing, CuratedSchemaProblem.TitleTooLong)
- text(name, CuratedSchemaEvent.MAX_NAME, CuratedSchemaProblem.NameMissing, CuratedSchemaProblem.NameTooLong)
- text(description, CuratedSchemaEvent.MAX_DESCRIPTION, CuratedSchemaProblem.DescriptionMissing, CuratedSchemaProblem.DescriptionTooLong)
-
- if (visibility == null) add(CuratedSchemaProblem.VisibilityMissing)
-
- picture?.let {
- if (!CuratedSchemaEvent.isHttpsUrl(it) || it.length > CuratedSchemaEvent.MAX_URL) {
- add(CuratedSchemaProblem.PictureNotHttps)
- }
- }
-
- domain?.let {
- if (!CuratedSchemaEvent.isDomain(it)) add(CuratedSchemaProblem.DomainInvalid)
- }
-
- if (fields.isEmpty()) add(CuratedSchemaProblem.NoFields)
-
- if (relays.any { !CuratedSchemaEvent.isRelayUrl(it) }) add(CuratedSchemaProblem.RelayInvalid)
- }
-
- fun isUsable(): Boolean = problems().isEmpty()
-}
-
-/** A reason a [CuratedSchema] cannot be published or believed. See [CuratedSchema.problems]. */
-enum class CuratedSchemaProblem {
- IdentifierMissing,
- IdentifierTooLong,
- TitleMissing,
- TitleTooLong,
- NameMissing,
- NameTooLong,
- DescriptionMissing,
- DescriptionTooLong,
- VisibilityMissing,
- PictureNotHttps,
- DomainInvalid,
- NoFields,
- RelayInvalid,
-}
-
-/**
- * Who may suggest to a list, and who it is meant for.
- *
- * Suggesting only. Curation is never delegated: a public list still has exactly
- * one curator, and the `p` tags on a closed list name extra *suggesters*.
- *
- * Relays are open, so [Private] is a convention clients follow rather than an
- * encryption anybody enforces. Nothing secret belongs in a private list.
- */
-enum class CuratedVisibility(val value: String) {
- /** Anyone may suggest; anyone may read. */
- Public("public"),
-
- /** The curator and the `p` tags may suggest; anyone may read. */
- Closed("closed"),
-
- /** The curator and the `p` tags may suggest; clients should show it only to them. */
- Private("private"),
- ;
-
- companion object {
- /** Case-insensitive, trimmed, and null for anything but the three words. */
- fun parse(value: String): CuratedVisibility? =
- value.trim().lowercase().let { word -> entries.firstOrNull { it.value == word } }
- }
-}
-
-/**
- * What kind of value a field takes. One validation rule each, in the NIP's
- * table; a form picks its input from this.
- *
- * [isNumeric] decides what `max` means: a maximum *value* for these, a maximum
- * *length* for everything else. The double duty is safe because a field is one
- * or the other, never both.
- */
-enum class CuratedFieldType(val value: String, val isNumeric: Boolean = false) {
- /** Single-line string, length-capped. */
- Text("text"),
-
- /** Multi-line string. */
- LongText("longtext"),
-
- /** One word, no whitespace -- `imdb:tt2821314`. */
- Token("token"),
-
- /** An http(s) URL; `https` in the config narrows it. */
- Url("url"),
-
- /** A URL that must be https, since browsers block insecure images. */
- Image("image"),
-
- /** One of the config's `options`. */
- Enum("enum"),
-
- /** An integer year, bounded by `min` and `max`. */
- Year("year", isNumeric = true),
-
- /** A positive integer number of seconds, bounded by `min` and `max`. */
- Duration("duration", isNumeric = true),
-
- /** An integer, bounded by `min` and `max`. */
- Number("number", isNumeric = true),
- ;
-
- companion object {
- fun parse(value: String): CuratedFieldType? = entries.firstOrNull { it.value == value }
- }
-}
-
-/**
- * Everything about a field beyond its name, type, optionality, placeholder and
- * label -- the JSON object in the seventh position of a `field` tag.
- *
- * Null throughout means "not said", and a key that is not said is not written,
- * so a config with nothing in it is `{}`. [others] carries keys this app does
- * not know, so editing a field another client wrote does not silently strip
- * what that client meant by it.
- */
-data class CuratedFieldConfig(
- /** The tag the field writes to. Defaults to the field's name; [CONTENT_TAG] means the body. */
- val tag: String? = null,
- /** A marker written as the tag's third element -- `["r", url, "watch"]`. */
- val marker: String? = null,
- /** Maximum characters for text-ish types, maximum value for numeric ones. */
- val max: Long? = null,
- /** Minimum value. Numeric types only. */
- val min: Long? = null,
- /** The allowed values of an enum. */
- val options: List? = null,
- /** Require https on a url. */
- val https: Boolean? = null,
- /** The tag may appear more than once. */
- val repeat: Boolean? = null,
- /** A regular expression the trimmed value must match end to end. */
- val pattern: String? = null,
- /** The client fills the value in and must not prompt for it. */
- val derived: Boolean? = null,
- /** Helper text under the input. */
- val hint: String? = null,
- /** Keys this app does not know, kept as they came. */
- val others: Map = emptyMap(),
-) {
- /** The blob as it goes into the tag. Known keys first, in the NIP's order, then [others]. */
- fun toJson(): String = buildJsonObject {
- tag?.let { put(KEY_TAG, it) }
- marker?.let { put(KEY_MARKER, it) }
- max?.let { put(KEY_MAX, it) }
- min?.let { put(KEY_MIN, it) }
- options?.let { put(KEY_OPTIONS, JsonArray(it.map(::JsonPrimitive))) }
- https?.let { put(KEY_HTTPS, it) }
- repeat?.let { put(KEY_REPEAT, it) }
- pattern?.let { put(KEY_PATTERN, it) }
- derived?.let { put(KEY_DERIVED, it) }
- hint?.let { put(KEY_HINT, it) }
- others.forEach { (key, value) -> put(key, value) }
- }.toString()
-
- companion object {
- /** The `tag` value meaning the event's content rather than a tag. */
- const val CONTENT_TAG = "content"
-
- private const val KEY_TAG = "tag"
- private const val KEY_MARKER = "marker"
- private const val KEY_MAX = "max"
- private const val KEY_MIN = "min"
- private const val KEY_OPTIONS = "options"
- private const val KEY_HTTPS = "https"
- private const val KEY_REPEAT = "repeat"
- private const val KEY_PATTERN = "pattern"
- private const val KEY_DERIVED = "derived"
- private const val KEY_HINT = "hint"
-
- private val KNOWN_KEYS = setOf(
- KEY_TAG, KEY_MARKER, KEY_MAX, KEY_MIN, KEY_OPTIONS,
- KEY_HTTPS, KEY_REPEAT, KEY_PATTERN, KEY_DERIVED, KEY_HINT,
- )
-
- /**
- * The blob read back, leniently.
- *
- * A malformed blob costs the field its constraints and not its life: the
- * NIP says a bad config must not invalidate the field, so anything that
- * is not a JSON object reads as no config at all, and a known key of the
- * wrong type is dropped rather than kept as something it is not.
- */
- fun fromJson(json: String?): CuratedFieldConfig {
- if (json.isNullOrBlank()) return CuratedFieldConfig()
-
- val obj = runCatching { CommonJson.parseToJsonElement(json) }.getOrNull() as? JsonObject
- ?: return CuratedFieldConfig()
-
- fun string(key: String): String? =
- (obj[key] as? JsonPrimitive)?.takeIf { it.isString }?.contentOrNull
-
- fun long(key: String): Long? =
- (obj[key] as? JsonPrimitive)?.takeUnless { it.isString }?.longOrNull
-
- fun boolean(key: String): Boolean? =
- (obj[key] as? JsonPrimitive)?.takeUnless { it.isString }?.booleanOrNull
-
- return CuratedFieldConfig(
- tag = string(KEY_TAG),
- marker = string(KEY_MARKER),
- max = long(KEY_MAX),
- min = long(KEY_MIN),
- options = (obj[KEY_OPTIONS] as? JsonArray)
- ?.mapNotNull { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.contentOrNull },
- https = boolean(KEY_HTTPS),
- repeat = boolean(KEY_REPEAT),
- pattern = string(KEY_PATTERN),
- derived = boolean(KEY_DERIVED),
- hint = string(KEY_HINT),
- others = obj.filterKeys { it !in KNOWN_KEYS },
- )
- }
- }
-}
-
-/**
- * One field an entry may -- or must -- carry.
- *
- * ```
- * ["field", name, type, "required" | "optional", placeholder, label, config]
- * ```
- *
- * The four things a form needs are positional and everything else is the JSON
- * blob in the last position, which is [CuratedFieldConfig].
- */
-data class CuratedField(
- /** Stable name; the key a form collects the value under. */
- val name: String,
- val type: CuratedFieldType,
- val isRequired: Boolean,
- /** An example value shown in an empty input. May be empty. */
- val placeholder: String = "",
- /** The human label for the input. Empty means [name]. */
- val label: String = "",
- val config: CuratedFieldConfig = CuratedFieldConfig(),
-) {
- /** The tag this field writes to; [CuratedFieldConfig.CONTENT_TAG] means the body. */
- fun tag(): String = config.tag ?: name
-
- fun labelOrName(): String = label.ifBlank { name }
-
- fun toTagArray(): Array = arrayOf(
- TAG_NAME,
- name,
- type.value,
- if (isRequired) REQUIRED else OPTIONAL,
- placeholder,
- label,
- config.toJson(),
- )
-
- companion object {
- const val TAG_NAME = "field"
-
- private const val REQUIRED = "required"
- private const val OPTIONAL = "optional"
-
- /**
- * The tag read back, or null for one that is not a field.
- *
- * Null for a blank name or an unknown type, which are the two things a
- * form cannot do anything with. Everything after them is optional: a
- * missing placeholder is empty, a missing label falls back to the name,
- * and a missing or malformed config is no config -- see
- * [CuratedFieldConfig.fromJson].
- */
- fun parse(tag: Array): CuratedField? {
- if (tag.size < 3 || tag[0] != TAG_NAME) return null
-
- val name = tag[1].trim()
- if (name.isEmpty()) return null
-
- val type = CuratedFieldType.parse(tag[2]) ?: return null
-
- return CuratedField(
- name = name,
- type = type,
- isRequired = tag.getOrNull(3) == REQUIRED,
- placeholder = tag.getOrNull(4) ?: "",
- label = tag.getOrNull(5) ?: "",
- config = CuratedFieldConfig.fromJson(tag.getOrNull(6)),
- )
- }
-
- /** The `title` field a schema gets when it left its own out. */
- val TITLE = CuratedField(
- name = "title",
- type = CuratedFieldType.Text,
- isRequired = true,
- label = "Title",
- config = CuratedFieldConfig(max = 200),
- )
-
- /**
- * The `d` field a schema gets when it left its own out. Derived, so a
- * form never asks for it: the client works an identifier out of the
- * entry itself.
- */
- val IDENTIFIER = CuratedField(
- name = "identifier",
- type = CuratedFieldType.Token,
- isRequired = true,
- label = "Identifier",
- config = CuratedFieldConfig(tag = "d", max = 80, derived = true),
- )
-
- /**
- * The two fields every list has, keyed by the tag each writes to and in
- * the order [CuratedSchema.normalized] puts them back -- title first, so
- * that `d` ends up in front of it.
- */
- val MANDATORY: List> = listOf(
- "title" to TITLE,
- "d" to IDENTIFIER,
- )
- }
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedSchemaEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedSchemaEvent.kt
deleted file mode 100644
index 37570a6e..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedSchemaEvent.kt
+++ /dev/null
@@ -1,206 +0,0 @@
-package press.mantra.compose.nostr.curated
-
-import com.vitorpamplona.quartz.nip01Core.core.Event
-import com.vitorpamplona.quartz.nip01Core.core.HexKey
-import com.vitorpamplona.quartz.nip01Core.core.Kind
-import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
-import kotlin.time.Clock
-
-/**
- * Kind 31889: a curated schema event, and the two kinds it governs.
- *
- * ```
- * 31889 curated schema the definition of a list, by its curator
- * 31888 curated suggestion an entry anyone proposes, in reply to the schema
- * 31890 curated canonical a suggestion the curator republished as the list's own
- * ```
- *
- * All three are addressable, so `kind:pubkey:d` names one entry and republishing
- * with the same `d` replaces it. That is what makes a schema revisable: editing
- * one signs a new event under the same coordinate, and every suggestion that
- * replied to the coordinate -- rather than to an event id -- stays attached.
- *
- * This object is the wire shape: tags in, tags out. [CuratedSchema] is what the
- * tags mean, and `GroupCuratedSchema` is a group's own signed one.
- */
-object CuratedSchemaEvent {
- const val KIND: Kind = 31889
-
- /** The kind a schema's suggestions are published under -- its `k` tag. */
- const val SUGGESTION_KIND: Kind = 31888
-
- /** The kind the curator republishes an accepted suggestion as. */
- const val CANONICAL_KIND: Kind = 31890
-
- /** Length caps on the identity tags, the reference module's. */
- const val MAX_IDENTIFIER = 100
- const val MAX_TITLE = 200
- const val MAX_NAME = 100
- const val MAX_DESCRIPTION = 500
- const val MAX_URL = 500
- const val MAX_DOMAIN = 253
-
- private const val TAG_IDENTIFIER = "d"
- private const val TAG_TITLE = "title"
- private const val TAG_NAME = "name"
- private const val TAG_DESCRIPTION = "description"
- private const val TAG_KIND = "k"
- private const val TAG_VISIBILITY = "visibility"
- private const val TAG_PICTURE = "picture"
- private const val TAG_DOMAIN = "domain"
- private const val TAG_REQUIRE_ANY = "require-any"
- private const val TAG_SUGGESTER = "p"
- private const val TAG_RELAY = "relay"
-
- /** The addressable coordinate: what a suggestion's `a` root names. */
- fun coordinate(pubkey: HexKey, identifier: String): String = "$KIND:$pubkey:$identifier"
-
- /**
- * The schema an event declares, or null when the event is not a usable one.
- *
- * Defensive in the way a relay demands. The wrong kind is null. Any of
- * [CuratedSchema.problems] is null -- a schema that would be rejected on
- * publish is rejected on read, and for the same reasons. A `field` tag that
- * will not parse is dropped and the rest are kept, which is the one place
- * the NIP asks for tolerance; a `require-any` naming fewer than two fields
- * says nothing and goes the same way.
- *
- * What comes back is [CuratedSchema.normalized]: the two mandatory fields
- * forced in, whatever the event said.
- */
- fun parse(event: Event): CuratedSchema? {
- if (event.kind != KIND) return null
-
- return read(event.tags, event.content).takeIf { it.isUsable() }?.normalized()
- }
-
- /**
- * The tags as a schema, whether or not they make a usable one.
- *
- * Split from [parse] so that a reader wanting the reasons -- the editor,
- * a test -- can ask [CuratedSchema.problems] of the result. The identity
- * values are trimmed, and the description falls back to the content the way
- * generic clients read it: the tag is authoritative and the content is the
- * mirror.
- */
- fun read(tags: Array>, content: String): CuratedSchema {
- fun first(name: String): String? =
- tags.firstOrNull { it.size > 1 && it[0] == name }?.get(1)
-
- fun all(name: String): List =
- tags.filter { it.size > 1 && it[0] == name }.map { it[1] }
-
- return CuratedSchema(
- identifier = first(TAG_IDENTIFIER)?.trim() ?: "",
- title = first(TAG_TITLE)?.trim() ?: "",
- name = first(TAG_NAME)?.trim() ?: "",
- description = (first(TAG_DESCRIPTION)?.trim()?.ifEmpty { null } ?: content).trim(),
- // Null for an absent or unrecognised word, and null stays null: see
- // the field's own note on why it is never defaulted.
- visibility = first(TAG_VISIBILITY)?.let(CuratedVisibility::parse),
- picture = first(TAG_PICTURE)?.trim()?.ifEmpty { null },
- domain = first(TAG_DOMAIN)?.trim()?.ifEmpty { null }?.let(::normalizeDomain),
- suggestionKind = first(TAG_KIND)?.trim()?.toIntOrNull() ?: SUGGESTION_KIND,
- fields = tags.mapNotNull(CuratedField::parse),
- requireAny = tags
- .filter { it.isNotEmpty() && it[0] == TAG_REQUIRE_ANY }
- .map { group -> group.drop(1).map { it.trim() }.filter { it.isNotEmpty() } }
- .filter { it.size > 1 },
- // Only what is a pubkey. A `p` tag holding anything else names nobody,
- // and keeping it would have the editor render it as an npub it is not.
- suggesters = all(TAG_SUGGESTER).map { it.trim().lowercase() }.filter(::isPubkey).distinct(),
- relays = all(TAG_RELAY).map { it.trim() }.distinct(),
- )
- }
-
- /**
- * The unsigned event that publishes [schema], for the group to sign.
- *
- * Built from the schema alone rather than from the group's last event, the
- * way a relay list is and for the same reason: a schema is one document
- * whose whole point in being re-signed is to replace the last one, and
- * carrying an old tag forward would make removing a field impossible.
- *
- * The content mirrors the description so that a client reading content
- * rather than tags still shows something; the tag is the authoritative one.
- */
- fun template(
- schema: CuratedSchema,
- createdAt: Long = Clock.System.now().epochSeconds,
- ): EventTemplate {
- val normalized = schema.normalized()
-
- val tags = buildList {
- add(arrayOf(TAG_IDENTIFIER, normalized.identifier))
- add(arrayOf(TAG_TITLE, normalized.title))
- add(arrayOf(TAG_NAME, normalized.name))
- add(arrayOf(TAG_DESCRIPTION, normalized.description))
- add(arrayOf(TAG_KIND, normalized.suggestionKind.toString()))
- // Absent rather than invented when the schema has none. Every reader,
- // this one included, then refuses the event -- which is the right
- // outcome for a schema that does not say who may suggest to it, and
- // one the editor never produces.
- normalized.visibility?.let { add(arrayOf(TAG_VISIBILITY, it.value)) }
-
- normalized.picture?.let { add(arrayOf(TAG_PICTURE, it)) }
- normalized.domain?.let { add(arrayOf(TAG_DOMAIN, normalizeDomain(it))) }
-
- normalized.fields.forEach { add(it.toTagArray()) }
- normalized.requireAny.forEach { group -> add(arrayOf(TAG_REQUIRE_ANY, *group.toTypedArray())) }
- normalized.suggesters.forEach { add(arrayOf(TAG_SUGGESTER, it)) }
- normalized.relays.forEach { add(arrayOf(TAG_RELAY, it)) }
- }
-
- return EventTemplate(
- createdAt = createdAt,
- kind = KIND,
- tags = tags.toTypedArray(),
- content = normalized.description,
- )
- }
-
- /**
- * A hostname and nothing else: at least two labels, each of letters, digits
- * and inner hyphens, no scheme, port or path, 253 characters at most. What
- * [normalizeDomain] leaves of a pasted URL is checked against this, so
- * `https://Bitcoin.MOV/` is accepted and stored as `bitcoin.mov`.
- */
- private val DOMAIN = Regex(
- "^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$"
- )
-
- /** Trim the things people paste around a bare hostname. */
- fun normalizeDomain(value: String): String = value
- .trim()
- .lowercase()
- .replace(Regex("^[a-z][a-z0-9+.-]*://"), "")
- .removePrefix("@")
- .replace(Regex("/.*$"), "")
- .replace(Regex(":\\d+$"), "")
- .removeSuffix(".")
-
- /** 64 lowercase hex characters: an x-only pubkey as nostr writes one. */
- fun isPubkey(value: String): Boolean =
- value.length == 64 && value.all { it in '0'..'9' || it in 'a'..'f' }
-
- fun isDomain(value: String): Boolean =
- normalizeDomain(value).let { it.length <= MAX_DOMAIN && DOMAIN.matches(it) }
-
- /**
- * An https URL with a host: the only kind of picture a schema may carry,
- * because it gets rendered and browsers block the rest on a secure page.
- */
- fun isHttpsUrl(value: String): Boolean = HTTPS_URL.matches(value.trim())
-
- private val HTTPS_URL = Regex("^https://[^\\s/?#]+[^\\s]*$", RegexOption.IGNORE_CASE)
-
- /**
- * A `ws://` or `wss://` URL with a host, which is what the NIP accepts in a
- * `relay` tag. The editor is stricter about what it will let a group sign --
- * see `GroupRelaySet.relayUrlOrNull` -- but a schema read off a relay is held
- * to the NIP's rule and not to this app's.
- */
- fun isRelayUrl(value: String): Boolean = RELAY_URL.matches(value.trim())
-
- private val RELAY_URL = Regex("^wss?://[^\\s/?#]+[^\\s]*$", RegexOption.IGNORE_CASE)
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt
index 4f4533d1..9ea861ce 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt
@@ -9,11 +9,6 @@ import press.mantra.compose.database.model.MarmotKeyPackage
import press.mantra.compose.managers.SharedKeyDerivation
import press.mantra.compose.managers.MarmotGroupCreation
import press.mantra.compose.managers.SubgroupManager
-import press.mantra.compose.nostr.GroupNostrProfile
-import press.mantra.compose.nostr.GroupCuratedEntry
-import press.mantra.compose.nostr.GroupCuratedSchema
-import press.mantra.compose.nostr.GroupPost
-import press.mantra.compose.nostr.GroupRelayList
import press.mantra.compose.database.model.Participant
import press.mantra.compose.database.model.intermdiate.LocalChatMessage
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
@@ -84,61 +79,6 @@ interface ChatRepository {
*/
suspend fun parentOf(chatRoomId: String): HexKey?
- /**
- * The kind:0 this group signed about itself, or null while it has signed
- * none.
- *
- * The group's identity on nostr is the room's own id, so it has one to
- * describe whether or not it ever has. Read off the group's signed events and
- * verified there -- see `GroupNostrProfile` -- so a room shows the profile its
- * own key signed or shows that it has none.
- */
- suspend fun groupNostrProfile(chatRoomId: String): GroupNostrProfile?
-
- /**
- * Where this group has said it lives on nostr: one entry per relay set, always
- * all of them.
- *
- * A set the group has never agreed comes back unsigned and empty rather than
- * missing, because "never said" is a state the screen has to show and a gap in
- * a list is not one. See `GroupRelayList.isAgreed`.
- */
- suspend fun groupRelayLists(chatRoomId: String): List
-
- /**
- * The kind:1s this group signed, newest first.
- *
- * The group speaking as itself, which is a different thing from a member
- * speaking in the room -- the transcript is the second. Unlike the profile and
- * the relay lists these accumulate rather than replace, so this is a list and
- * an empty one means the group has said nothing publicly.
- */
- suspend fun groupPosts(chatRoomId: String): List
-
- /**
- * The curated schemas this group signed -- the lists it curates -- one per
- * identifier, most recently signed first.
- *
- * Kind 31889 is addressable, so an edit is a newer event under the same
- * identifier and the newest wins per list, the way the profile does; two
- * identifiers are two lists and both come back. Empty in a group that
- * curates nothing, which is every group until somebody proposes one.
- */
- suspend fun groupCuratedSchemas(chatRoomId: String): List
-
- /**
- * The canonical entries this group signed -- the suggestions it accepted
- * into the lists it curates -- one per coordinate, most recently signed
- * first.
- *
- * Kind 31890 is addressable, so a re-curation is a newer event under the
- * same identifier and the newest wins, the way a schema's edit does. Each
- * is read against the schema it replies to, and an entry whose list this
- * device holds no schema for is left out. Empty in a group that has
- * accepted nothing, which is every group until a quorum signs an entry.
- */
- suspend fun groupCuratedEntries(chatRoomId: String): List
-
/**
* One event the group signed, as the group signed it, or null when this
* device does not hold it or it was signed in some other room.
@@ -331,21 +271,6 @@ interface ChatRepository {
override suspend fun parentOf(chatRoomId: String): HexKey? = null
- override suspend fun groupNostrProfile(chatRoomId: String): GroupNostrProfile? = null
-
- override suspend fun groupRelayLists(chatRoomId: String): List =
- GroupRelayList.allAmong(emptyList(), chatRoomId)
-
- override suspend fun groupPosts(chatRoomId: String): List = emptyList()
-
- override suspend fun groupCuratedSchemas(
- chatRoomId: String
- ): List = emptyList()
-
- override suspend fun groupCuratedEntries(
- chatRoomId: String
- ): List = emptyList()
-
override suspend fun groupSignedEvent(
chatRoomId: String,
id: HexKey
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt
index cc6f7003..2ea083e5 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt
@@ -175,16 +175,6 @@ interface NostrRepository {
suspend fun observeNostrFeed(synchronizationFilter: SynchronizationFilter): Flow>
- /**
- * Every stored event matching [synchronizationFilter], newest first, for as long as
- * the caller collects.
- *
- * The filter is applied whole -- see `NostrEventFilterQuery` -- where [observeNostrFeed]
- * recognises a handful of shapes and falls back to text notes for the rest. A reader
- * that queued the same filter against a relay watches the answer arrive through this.
- */
- suspend fun observeNostrEventsMatching(synchronizationFilter: SynchronizationFilter): Flow>
-
suspend fun getNegentropicNostrFeedIds(
synchronizationFilters: Array,
applyLimits: Boolean = true
@@ -395,10 +385,6 @@ interface NostrRepository {
TODO("Not yet implemented")
}
- override suspend fun observeNostrEventsMatching(synchronizationFilter: SynchronizationFilter): Flow> {
- TODO("Not yet implemented")
- }
-
override suspend fun getNegentropicNostrFeedIds(
synchronizationFilters: Array,
applyLimits: Boolean
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt
index 926ab6c5..7b11f466 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt
@@ -1,13 +1,7 @@
package press.mantra.compose.text
import com.vitorpamplona.quartz.nip01Core.core.Event
-import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
-import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
-import press.mantra.compose.extensions.shortened
import press.mantra.compose.managers.SharedKeyDerivation
-import press.mantra.compose.nostr.GroupRelaySet
-import press.mantra.compose.nostr.curated.CuratedEntryEvent
-import press.mantra.compose.nostr.curated.CuratedSchemaEvent
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent
import press.mantra.compose.nostr.nip30303.ArtifactEvent
@@ -161,70 +155,6 @@ object ProposedEvent {
).joinToString(" · ")
)
- // The group's own profile. Named by what the group would be called and
- // what it would say about itself, which is the whole of what is being
- // decided; the JSON in the content is the one thing a signer should not
- // have to read to find that out.
- MetadataEvent.KIND -> Summary(
- label = "Nostr profile",
- detail = MetadataEvent(
- event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig
- ).contactMetaData()?.let { metadata ->
- listOfNotNull(
- metadata.anyName()?.takeIf { it.isNotBlank() },
- metadata.about?.takeIf { it.isNotBlank() }
- ).joinToString(" · ").ifEmpty { "An empty profile" }
- } ?: "Unreadable profile"
- )
-
- // A post in the group's name. The words are what is being decided, so
- // they are the detail rather than a count of them -- the same call the
- // translated-passage arm makes.
- TextNoteEvent.KIND -> Summary(label = "Post", detail = event.content)
-
- // One of the group's relay lists, shown as hosts: every entry in one
- // starts wss://, so the scheme is the part that never tells two apart.
- // An empty one is a list the group is withdrawing, and says so.
- in GroupRelaySet.KINDS -> {
- val set = GroupRelaySet.entries.first { it.kind == event.kind }
-
- Summary(
- label = "${set.name} relay list",
- detail = set.parse(event.tags).joinToString { it.displayUrl() }.ifEmpty { "No relays" }
- )
- }
-
- // A list the group would curate. A member signing this is agreeing to
- // take suggestions from whoever the visibility admits and to be the one
- // key that accepts them, so the summary is the list's name, who may
- // suggest, and how much an entry asks for -- the content is only the
- // description, which the detail screen shows whole.
- CuratedSchemaEvent.KIND -> Summary(
- label = "Curated schema",
- detail = CuratedSchemaEvent.read(event.tags, event.content).let { schema ->
- listOfNotNull(
- schema.displayName().ifBlank { null },
- schema.visibility?.value,
- schema.fields.size.let { "$it ${if (it == 1) "field" else "fields"}" }
- ).joinToString(" · ")
- }
- )
-
- // An entry the group would take into one of its lists. A member signing
- // this is agreeing that the list stands behind this title, so the
- // summary is the title and who suggested it -- the credit the entry
- // carries -- and not its fields, which the form showed whole before the
- // proposal was made and the queue shows again once it lands.
- CuratedEntryEvent.CANONICAL_KIND -> Summary(
- label = "Curated entry",
- detail = CuratedEntryEvent.headline(event).let { entry ->
- listOfNotNull(
- entry.title.ifBlank { entry.identifier }.ifBlank { null },
- entry.source?.let { "suggested by ${it.pubKey.shortened()}" }
- ).joinToString(" · ").ifEmpty { "An untitled entry" }
- }
- )
-
else -> Summary(label = "Event of kind ${event.kind}", detail = event.content)
}
}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AcceptCuratedSuggestionScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AcceptCuratedSuggestionScreen.kt
deleted file mode 100644
index dd0d5544..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AcceptCuratedSuggestionScreen.kt
+++ /dev/null
@@ -1,839 +0,0 @@
-package press.mantra.compose.ui.composable
-
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.ExperimentalLayoutApi
-import androidx.compose.foundation.layout.FlowRow
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.imePadding
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.foundation.text.input.TextFieldLineLimits
-import androidx.compose.foundation.text.input.TextFieldState
-import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Add
-import androidx.compose.material.icons.filled.CheckCircle
-import androidx.compose.material.icons.filled.Delete
-import androidx.compose.material.icons.filled.Draw
-import androidx.compose.material3.BottomAppBar
-import androidx.compose.material3.BottomAppBarDefaults
-import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
-import androidx.compose.material3.ExtendedFloatingActionButton
-import androidx.compose.material3.FilterChip
-import androidx.compose.material3.FloatingActionButtonDefaults
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.OutlinedTextFieldDefaults
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.SnackbarHost
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.material3.TopAppBar
-import androidx.compose.material3.contentColorFor
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.semantics.disabled
-import androidx.compose.ui.semantics.semantics
-import androidx.compose.ui.text.font.FontFamily
-import androidx.compose.ui.text.input.KeyboardType
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.lifecycle.viewmodel.compose.viewModel
-import com.vitorpamplona.quartz.nip01Core.core.HexKey
-import mantra.composeapp.generated.resources.Res
-import mantra.composeapp.generated.resources.a_whole_number
-import mantra.composeapp.generated.resources.add_another
-import mantra.composeapp.generated.resources.at_least_n
-import mantra.composeapp.generated.resources.at_least_one_of_these_is_needed
-import mantra.composeapp.generated.resources.at_most_n
-import mantra.composeapp.generated.resources.at_most_n_characters
-import mantra.composeapp.generated.resources.could_not_ask_the_group_to_sign_this_entry
-import mantra.composeapp.generated.resources.field_and_problem
-import mantra.composeapp.generated.resources.filled_in_by_the_suggester_s_app_and_kept
-import mantra.composeapp.generated.resources.kept_from_the_suggestion
-import mantra.composeapp.generated.resources.more_than_zero
-import mantra.composeapp.generated.resources.must_match_the_pattern
-import mantra.composeapp.generated.resources.one_word_without_spaces
-import mantra.composeapp.generated.resources.only_one_value_for_this_field
-import mantra.composeapp.generated.resources.pick_one_of_the_options
-import mantra.composeapp.generated.resources.propose_entry
-import mantra.composeapp.generated.resources.suggested_by_on
-import mantra.composeapp.generated.resources.take_this_suggestion_into_the_list
-import mantra.composeapp.generated.resources.that_is_not_a_web_address
-import mantra.composeapp.generated.resources.that_must_be_an_https_address
-import mantra.composeapp.generated.resources.the_entry_cannot_be_signed_as_it_is
-import mantra.composeapp.generated.resources.the_group_already_has_an_entry_for_this
-import mantra.composeapp.generated.resources.the_group_signs_an_entry_so_it_takes_a_quorum
-import mantra.composeapp.generated.resources.this_entry_cannot_be_signed_as_it_is
-import mantra.composeapp.generated.resources.this_field_is_required
-import mantra.composeapp.generated.resources.this_group_has_no_shared_key_to_sign_an_entry
-import org.jetbrains.compose.resources.stringResource
-import press.mantra.compose.database.model.ChatRoom
-import press.mantra.compose.database.model.GroupSignedEvent
-import press.mantra.compose.database.model.NostrEvent
-import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.extensions.toFormattedTimeAndDateString
-import press.mantra.compose.nostr.CuratedSuggestion
-import press.mantra.compose.nostr.GroupCuratedSchema
-import press.mantra.compose.nostr.curated.CuratedEntry
-import press.mantra.compose.nostr.curated.CuratedEntryProblem
-import press.mantra.compose.nostr.curated.CuratedField
-import press.mantra.compose.nostr.curated.CuratedFieldConfig
-import press.mantra.compose.nostr.curated.CuratedFieldType
-import press.mantra.compose.nostr.curated.CuratedSchema
-import press.mantra.compose.nostr.curated.CuratedSchemaEvent
-import press.mantra.compose.nostr.curated.CuratedVisibility
-import press.mantra.compose.repository.ChatRepository
-import press.mantra.compose.repository.FrostSigningRepository
-import press.mantra.compose.repository.NostrRepository
-import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
-import press.mantra.compose.ui.composable.navigation.routes.Route
-import press.mantra.compose.ui.composable.widgets.Decorative
-import press.mantra.compose.ui.composable.widgets.ErrorState
-import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
-import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
-import press.mantra.compose.ui.composable.widgets.NavigateBackButton
-import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
-import press.mantra.compose.ui.composable.widgets.rememberNotifier
-import press.mantra.compose.ui.theme.ConformancePreviews
-import press.mantra.compose.ui.theme.MantraTheme
-import press.mantra.compose.ui.theme.readableContent
-import press.mantra.compose.ui.theme.spacing
-import press.mantra.compose.ui.view.model.AcceptCuratedSuggestionViewModel
-import press.mantra.compose.ui.view.state.AcceptCuratedSuggestionUIState
-import kotlin.time.Instant
-
-/**
- * The form for taking one suggestion into a list the group curates.
- *
- * The queue screen shows what was suggested; this is where the group decides
- * what the list will say. Every field the schema's form asks for is an input
- * seeded from the suggestion, because curation is editorial -- the NIP lets
- * the curator fix a year or swap a poster on the way through, and a button
- * that could only say yes would leave a typo in the list forever. What the
- * form does not ask for it shows: the identifier, which the entry keeps so
- * that accepting it twice revises one entry rather than making two, and the
- * fields the suggester's app filled in, which this app has no way to fill in
- * itself.
- *
- * **The button proposes rather than saves.** The entry goes out as one kind
- * 31890 for the room's quorum to put its key to, pointing back at the
- * suggestion it came from, and the screen hands over to the signing session it
- * opened. Nothing about the list has changed by the time this screen closes.
- * The form is driven by a schema this screen has never seen, so a value the
- * schema refuses is named under its own input rather than found out after a
- * quorum has signed.
- */
-@OptIn(
- ExperimentalMaterial3ExpressiveApi::class,
- ExperimentalMaterial3Api::class,
- ExperimentalLayoutApi::class
-)
-@Composable
-fun AcceptCuratedSuggestionScreen(
- activeUserPublicKey: HexKey,
- chatRoomId: String,
- relayHint: String?,
- identifier: String,
- suggestionId: String,
- initialAcceptCuratedSuggestionUIState: AcceptCuratedSuggestionUIState =
- AcceptCuratedSuggestionUIState.Loading,
- nostrRepository: NostrRepository,
- chatRepository: ChatRepository,
- frostSigningRepository: FrostSigningRepository,
- onNavigateBack: () -> Unit,
- onNavigateToRouteAndPopUpInclusive: (Route) -> Unit,
-) {
- val acceptCuratedSuggestionViewModel: AcceptCuratedSuggestionViewModel = viewModel(
- factory = AcceptCuratedSuggestionViewModel.factory(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = relayHint,
- identifier = identifier,
- suggestionId = suggestionId,
- initialAcceptCuratedSuggestionUIState = initialAcceptCuratedSuggestionUIState,
- nostrRepository = nostrRepository,
- chatRepository = chatRepository,
- frostSigningRepository = frostSigningRepository
- )
- )
-
- LaunchedEffect(true) {
- if (initialAcceptCuratedSuggestionUIState == AcceptCuratedSuggestionUIState.Loading) {
- acceptCuratedSuggestionViewModel.initiate()
- }
- }
-
- // Read out here rather than in a click handler: both are composable and an
- // onClick lambda is not. The scope is the caller's so a message survives this
- // screen being replaced by the signing session.
- val notify = rememberNotifier(rememberCoroutineScope())
- val couldNotPropose = stringResource(Res.string.could_not_ask_the_group_to_sign_this_entry)
-
- ScreenStateTransition(acceptCuratedSuggestionViewModel.acceptCuratedSuggestionUIState) { uiState ->
- when (val state = uiState) {
- is AcceptCuratedSuggestionUIState.Error -> {
- // Nothing to retry: the room, the schema and the suggestion came
- // from navigation arguments, and reading them again with the
- // same ones fails the same way.
- ErrorState(message = state.message, onRetry = null)
- }
-
- AcceptCuratedSuggestionUIState.Loading -> {
- LoadingDataIndicator()
- }
-
- is AcceptCuratedSuggestionUIState.Loaded -> {
- val canSign = state.canSign
-
- // The inputs come from the state rather than from whatever loaded
- // it, so a screen handed a loaded state -- a preview, a layout
- // test -- fills in the same as the app does. Seeding runs once.
- LaunchedEffect(state) {
- acceptCuratedSuggestionViewModel.seedWorkingCopy(state)
- }
-
- // M3 gives a FAB no `enabled`, so borrow the disabled colours every
- // other button in the app uses rather than inventing a shade here.
- val buttonColors = ButtonDefaults.buttonColors()
-
- Scaffold(
- snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
- modifier = Modifier.imePadding(),
- topBar = {
- TopAppBar(
- title = { state.localChatRoom.RenderChatRoomTitleText() },
- navigationIcon = { NavigateBackButton(onNavigateBack) }
- )
- },
- bottomBar = {
- BottomAppBar(
- actions = {},
- floatingActionButton = {
- ExtendedFloatingActionButton(
- modifier = if (canSign) {
- Modifier
- } else {
- // Looking unavailable is not being unavailable.
- Modifier.semantics { disabled() }
- },
- containerColor = if (canSign) {
- FloatingActionButtonDefaults.containerColor
- } else {
- buttonColors.disabledContainerColor
- },
- contentColor = if (canSign) {
- contentColorFor(FloatingActionButtonDefaults.containerColor)
- } else {
- buttonColors.disabledContentColor
- },
- onClick = {
- if (!canSign) return@ExtendedFloatingActionButton
-
- acceptCuratedSuggestionViewModel.proposeEntry(
- state = state,
- onSuccess = { sessionId ->
- // Onto the session rather than back
- // to the queue. The entry is not in
- // the list yet -- it goes in when a
- // quorum signs -- so landing on a
- // queue still showing the row as
- // waiting would read as a failure.
- onNavigateToRouteAndPopUpInclusive.invoke(
- FrostSigningRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- sessionId = sessionId
- )
- )
- },
- // The reasons are drawn on the form
- // itself, under the inputs they are
- // about; a snackbar would name a
- // problem and scroll away from it.
- onInvalid = {},
- // Stays on the form with the edits still
- // in it: a failure to open a session is
- // no reason to throw away the typing.
- onFailure = { notify(couldNotPropose) }
- )
- }
- ) {
- Icon(Icons.Default.Draw, contentDescription = "Propose entry")
- Text(stringResource(Res.string.propose_entry))
- }
- }
- )
- }
- ) { innerPadding ->
- AcceptCuratedSuggestionForm(
- state = state,
- viewModel = acceptCuratedSuggestionViewModel,
- modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
- )
- }
- }
- }
- }
-}
-
-/**
- * The form under the bars: what is being taken up and into what, the reasons
- * it cannot be as it stands, an input per field the schema asks for, and the
- * fields it keeps.
- *
- * A lazy list rather than a scrolling column, the way the schema editor is,
- * because the number of inputs is the schema's to decide and a long form is
- * still one screen. Split out so a layout test can render it without the bars.
- */
-@OptIn(ExperimentalLayoutApi::class)
-@Composable
-internal fun AcceptCuratedSuggestionForm(
- state: AcceptCuratedSuggestionUIState.Loaded,
- viewModel: AcceptCuratedSuggestionViewModel,
- modifier: Modifier = Modifier
-) {
- val schema = state.schema.schema
- val suggestion = state.suggestion
- val editable = AcceptCuratedSuggestionViewModel.editableFields(state)
- val kept = AcceptCuratedSuggestionViewModel.keptFields(state)
- val entryProblems = viewModel.problemsAboutTheEntry(state)
-
- LazyColumn(
- modifier = modifier,
- contentPadding = PaddingValues(
- horizontal = MaterialTheme.spacing.screenMargin,
- vertical = MaterialTheme.spacing.itemGap
- ),
- verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- item {
- Text(
- text = suggestion.entry.title,
- style = MaterialTheme.typography.titleLarge,
- textAlign = TextAlign.Center,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis
- )
- }
-
- item {
- Text(
- text = stringResource(
- Res.string.suggested_by_on,
- suggestion.suggesterName(),
- suggestion.suggestedAt.toFormattedTimeAndDateString()
- ),
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- textAlign = TextAlign.Center
- )
- }
-
- item {
- Text(
- text = stringResource(Res.string.take_this_suggestion_into_the_list, schema.displayName()),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- textAlign = TextAlign.Center
- )
- }
-
- item {
- Text(
- text = stringResource(Res.string.the_group_signs_an_entry_so_it_takes_a_quorum),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- textAlign = TextAlign.Center
- )
- }
-
- // Said up front rather than left to the queue's mark: a member who did
- // not notice would propose what they took for a new entry and get a
- // revision, which is the right outcome but a surprising one.
- if (suggestion.isCurated) {
- item {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Icon(
- Icons.Default.CheckCircle,
- contentDescription = Decorative,
- tint = MaterialTheme.colorScheme.primary
- )
-
- Spacer(modifier = Modifier.width(MaterialTheme.spacing.relatedGap))
-
- Text(
- text = stringResource(Res.string.the_group_already_has_an_entry_for_this),
- style = MaterialTheme.typography.bodySmall
- )
- }
- }
- }
-
- if (!state.canSign) {
- item {
- Text(
- text = stringResource(Res.string.this_group_has_no_shared_key_to_sign_an_entry),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.error,
- textAlign = TextAlign.Center
- )
- }
- }
-
- // The problems no input can carry: about the entry as a whole, or about
- // a field the form keeps rather than asks for. Named, and all at once,
- // for the reason the schema editor gives -- a button that does nothing
- // is indistinguishable from a missed tap.
- if (entryProblems.isNotEmpty()) {
- item {
- Column(
- modifier = Modifier.fillMaxWidth(),
- verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)
- ) {
- Text(
- text = stringResource(Res.string.this_entry_cannot_be_signed_as_it_is),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.error
- )
-
- entryProblems.forEach { problem ->
- val field = schema.fields.firstOrNull { it.name == problem.field }
-
- Text(
- text = stringResource(
- Res.string.field_and_problem,
- field?.labelOrName() ?: problem.field,
- problem.message(field, schema)
- ),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.error
- )
- }
- }
- }
- }
-
- editable.forEach { field ->
- item(key = field.name) {
- val inputs = viewModel.inputs[field.name].orEmpty()
- val problem = viewModel.problemsFor(field.name).firstOrNull()
-
- EntryFieldEditor(
- field = field,
- schema = schema,
- inputs = inputs,
- problem = problem,
- onAddValue = { viewModel.addValue(field.name) },
- onRemoveValue = { index -> viewModel.removeValue(field.name, index) }
- )
- }
- }
-
- if (kept.isNotEmpty()) {
- item {
- Column(
- modifier = Modifier.fillMaxWidth(),
- verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)
- ) {
- Spacer(modifier = Modifier.height(MaterialTheme.spacing.itemGap))
-
- Text(
- modifier = Modifier.fillMaxWidth(),
- text = stringResource(Res.string.kept_from_the_suggestion),
- style = MaterialTheme.typography.titleSmall
- )
-
- Text(
- text = stringResource(Res.string.filled_in_by_the_suggester_s_app_and_kept),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- }
-
- kept.forEach { field ->
- item(key = "kept:${field.name}") {
- KeptField(field = field, values = suggestion.entry.values[field.name].orEmpty())
- }
- }
- }
- }
-}
-
-/**
- * The inputs for one field, in the shape its type asks for.
- *
- * An enum is its options as chips, one selected, because typing a word that
- * has to be one of six is a way to misspell it; everything else is a text
- * field with the keyboard its type wants. A field that repeats is one input
- * per value with a way to drop each and a way to add one, since the
- * suggestion may have carried two watch links and the list may want a third.
- * The schema's hint sits under the input until a problem takes its place.
- */
-@OptIn(ExperimentalLayoutApi::class)
-@Composable
-private fun EntryFieldEditor(
- field: CuratedField,
- schema: CuratedSchema,
- inputs: List,
- problem: CuratedEntryProblem?,
- onAddValue: () -> Unit,
- onRemoveValue: (index: Int) -> Unit
-) {
- val message = problem?.message(field, schema)
- val options = field.config.options
-
- Column(
- modifier = Modifier.fillMaxWidth(),
- verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)
- ) {
- if (field.type == CuratedFieldType.Enum && !options.isNullOrEmpty()) {
- EnumInput(field = field, options = options, input = inputs.firstOrNull(), message = message)
- } else {
- inputs.forEachIndexed { index, input ->
- Row(verticalAlignment = Alignment.CenterVertically) {
- EntryTextField(
- modifier = Modifier.weight(1f),
- state = input,
- field = field,
- // A problem is about the field, and the field is every
- // input; the first is where a reader's eye lands.
- message = message.takeIf { index == 0 },
- isError = problem != null
- )
-
- if (field.config.repeat == true) {
- IconButton(onClick = { onRemoveValue(index) }) {
- Icon(
- Icons.Default.Delete,
- contentDescription = "Remove this value",
- tint = MaterialTheme.colorScheme.error
- )
- }
- }
- }
- }
-
- if (field.config.repeat == true) {
- TextButton(onClick = onAddValue) {
- Icon(Icons.Default.Add, contentDescription = Decorative)
- Spacer(modifier = Modifier.width(MaterialTheme.spacing.relatedGap))
- Text(stringResource(Res.string.add_another))
- }
- }
- }
- }
-}
-
-/**
- * An enum as chips. The selection is written into the field's one input, so
- * the draft reads an enum the way it reads everything else; a value the
- * suggestion carried that is not among the options is shown as an unselected
- * set with the problem under it, rather than quietly dropped.
- */
-@OptIn(ExperimentalLayoutApi::class)
-@Composable
-private fun EnumInput(
- field: CuratedField,
- options: List,
- input: TextFieldState?,
- message: String?
-) {
- val selected = input?.text?.toString()
-
- Text(
- text = field.labelOrName(),
- style = MaterialTheme.typography.labelMedium,
- color = if (message != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant
- )
-
- FlowRow(
- horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap),
- verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)
- ) {
- options.forEach { option ->
- FilterChip(
- selected = option == selected,
- onClick = { input?.setTextAndPlaceCursorAtEnd(option) },
- label = { Text(option) }
- )
- }
- }
-
- (message ?: field.config.hint)?.let {
- Text(
- text = it,
- style = MaterialTheme.typography.bodySmall,
- color = if (message != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
-}
-
-/**
- * One text input of the form, in the shape the app's other forms use: the
- * borderless outlined field over the bottom bar's tint, labelled as the
- * schema labels it and placeholdered as the schema says.
- *
- * The keyboard follows the type -- digits for a year, a URL keyboard for a
- * link -- and a long text gets room to be one. The supporting line is the
- * problem when there is one and the schema's hint when there is not.
- */
-@Composable
-private fun EntryTextField(
- state: TextFieldState,
- field: CuratedField,
- message: String?,
- isError: Boolean,
- modifier: Modifier = Modifier
-) {
- val keyboardType = when (field.type) {
- CuratedFieldType.Year, CuratedFieldType.Duration, CuratedFieldType.Number -> KeyboardType.Number
- CuratedFieldType.Url, CuratedFieldType.Image -> KeyboardType.Uri
- else -> KeyboardType.Text
- }
-
- OutlinedTextField(
- modifier = modifier.fillMaxWidth().background(BottomAppBarDefaults.containerColor),
- state = state,
- lineLimits = if (field.type == CuratedFieldType.LongText) {
- TextFieldLineLimits.MultiLine(minHeightInLines = 3)
- } else {
- TextFieldLineLimits.SingleLine
- },
- isError = isError,
- keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
- colors = OutlinedTextFieldDefaults.colors(
- focusedBorderColor = Color.Transparent,
- unfocusedBorderColor = Color.Transparent,
- disabledBorderColor = Color.Transparent
- ),
- label = {
- Text(text = field.labelOrName())
- },
- placeholder = field.placeholder.takeIf { it.isNotBlank() }?.let { { Text(text = it) } },
- supportingText = (message ?: field.config.hint)?.let { { Text(text = it) } }
- )
-}
-
-/**
- * A field the form keeps rather than asks for: the schema's label over the
- * values as the suggestion carried them, the way the queue's sheet shows an
- * entry. Monospaced where the value is a thing rather than prose.
- */
-@Composable
-private fun KeptField(field: CuratedField, values: List) {
- val isMonospaced = when (field.type) {
- CuratedFieldType.Token, CuratedFieldType.Url, CuratedFieldType.Image -> true
- else -> false
- }
-
- Column(modifier = Modifier.fillMaxWidth()) {
- Text(
- text = field.labelOrName(),
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
-
- Text(
- text = values.joinToString("\n"),
- style = MaterialTheme.typography.bodyMedium,
- fontFamily = if (isMonospaced) FontFamily.Monospace else null,
- color = MaterialTheme.colorScheme.onSurface,
- modifier = Modifier.fillMaxWidth()
- )
- }
-}
-
-/**
- * One problem in words, for the input it is about.
- *
- * The bounds are read off the field's config, since "at most 500" says
- * something and "too long" does not; a `require-any` rule names the other
- * fields it could have been satisfied by, as the schema labels them.
- */
-@Composable
-internal fun CuratedEntryProblem.message(field: CuratedField?, schema: CuratedSchema): String = when (reason) {
- CuratedEntryProblem.Reason.Missing,
- CuratedEntryProblem.Reason.Empty -> stringResource(Res.string.this_field_is_required)
-
- CuratedEntryProblem.Reason.Repeated -> stringResource(Res.string.only_one_value_for_this_field)
- CuratedEntryProblem.Reason.NotOneWord -> stringResource(Res.string.one_word_without_spaces)
- CuratedEntryProblem.Reason.NotAUrl -> stringResource(Res.string.that_is_not_a_web_address)
- CuratedEntryProblem.Reason.NotHttps -> stringResource(Res.string.that_must_be_an_https_address)
- CuratedEntryProblem.Reason.NotAnOption -> stringResource(Res.string.pick_one_of_the_options)
- CuratedEntryProblem.Reason.NotAWholeNumber -> stringResource(Res.string.a_whole_number)
- CuratedEntryProblem.Reason.NotPositive -> stringResource(Res.string.more_than_zero)
-
- CuratedEntryProblem.Reason.BelowMinimum ->
- stringResource(Res.string.at_least_n, field?.config?.min?.toString() ?: "")
-
- CuratedEntryProblem.Reason.AboveMaximum ->
- stringResource(Res.string.at_most_n, field?.config?.max?.toString() ?: "")
-
- CuratedEntryProblem.Reason.TooLong ->
- stringResource(Res.string.at_most_n_characters, field?.config?.max?.toString() ?: "")
-
- CuratedEntryProblem.Reason.PatternMismatch ->
- stringResource(Res.string.must_match_the_pattern, field?.config?.pattern ?: "")
-
- CuratedEntryProblem.Reason.RequireAnyUnmet -> stringResource(
- Res.string.at_least_one_of_these_is_needed,
- schema.requireAny.firstOrNull { it.firstOrNull() == this.field }
- .orEmpty()
- .joinToString { name -> schema.fields.firstOrNull { it.name == name }?.labelOrName() ?: name }
- )
-
- // None of these can come out of the form: the builder writes the kind, the
- // root and the source, and the group is the curator. Named all the same,
- // since a check that cannot fail is the one that does.
- CuratedEntryProblem.Reason.WrongKind,
- CuratedEntryProblem.Reason.NotAReply,
- CuratedEntryProblem.Reason.RepliesElsewhere,
- CuratedEntryProblem.Reason.NotPermitted,
- CuratedEntryProblem.Reason.NotTheCurator,
- CuratedEntryProblem.Reason.SourceMalformed -> stringResource(Res.string.the_entry_cannot_be_signed_as_it_is)
-}
-
-@ConformancePreviews
-@Composable
-private fun AcceptCuratedSuggestionScreenPreview() {
- val chatRoomId = "d4c3b2a1".repeat(8)
- val suggester = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860"
-
- val schema = GroupCuratedSchema(
- signedEvent = GroupSignedEvent(
- id = "c".repeat(64),
- chatRoomId = chatRoomId,
- publicKey = chatRoomId,
- kind = CuratedSchemaEvent.KIND,
- tags = emptyArray(),
- content = "",
- signature = "f".repeat(128),
- createdAt = Instant.fromEpochSeconds(1_700_000_000)
- ),
- schema = CuratedSchema(
- identifier = "films",
- title = "Film suggestion",
- name = "Films worth translating",
- description = "Films the group would subtitle.",
- visibility = CuratedVisibility.Public,
- fields = listOf(
- CuratedField.IDENTIFIER,
- CuratedField.TITLE,
- CuratedField("year", CuratedFieldType.Year, isRequired = false, label = "Year"),
- CuratedField(
- "type",
- CuratedFieldType.Enum,
- isRequired = true,
- label = "Type",
- config = CuratedFieldConfig(options = listOf("movie", "documentary", "short"))
- ),
- CuratedField(
- "watchUrl",
- CuratedFieldType.Url,
- isRequired = false,
- label = "Watch URL",
- config = CuratedFieldConfig(tag = "r", marker = "watch", repeat = true)
- ),
- CuratedField(
- "description",
- CuratedFieldType.LongText,
- isRequired = false,
- label = "Why",
- config = CuratedFieldConfig(tag = CuratedFieldConfig.CONTENT_TAG)
- ),
- CuratedField(
- "hashtags",
- CuratedFieldType.Token,
- isRequired = false,
- label = "Hashtags",
- config = CuratedFieldConfig(tag = "t", repeat = true, derived = true)
- ),
- ),
- relays = listOf("wss://relay.one.example")
- )
- )
-
- val suggestion = CuratedSuggestion(
- entry = CuratedEntry(
- kind = CuratedSchemaEvent.SUGGESTION_KIND,
- id = "a".repeat(64),
- pubKey = suggester,
- createdAt = 1_700_000_900,
- identifier = "the-rise-and-rise-of-bitcoin-2014",
- title = "The Rise and Rise of Bitcoin",
- values = mapOf(
- "identifier" to listOf("the-rise-and-rise-of-bitcoin-2014"),
- "title" to listOf("The Rise and Rise of Bitcoin"),
- "year" to listOf("2014"),
- "type" to listOf("documentary"),
- "watchUrl" to listOf("http://bitcoindoc.com/"),
- "description" to listOf("Follows the early Bitcoin community from 2011 onward."),
- "hashtags" to listOf("bitcoin", "documentary"),
- )
- ),
- event = NostrEvent(
- id = "a".repeat(64),
- pubKey = suggester,
- kind = CuratedSchemaEvent.SUGGESTION_KIND,
- tags = emptyArray(),
- content = "",
- sig = "b".repeat(128),
- createdAt = Instant.fromEpochSeconds(1_700_000_900)
- ),
- suggester = null,
- isCurated = false
- )
-
- MantraTheme {
- Surface(modifier = Modifier.fillMaxSize()) {
- AcceptCuratedSuggestionScreen(
- activeUserPublicKey = "u".repeat(64),
- chatRoomId = chatRoomId,
- relayHint = null,
- identifier = "films",
- suggestionId = suggestion.id,
- initialAcceptCuratedSuggestionUIState = AcceptCuratedSuggestionUIState.Loaded(
- localChatRoom = LocalChatRoom(
- chatRoom = ChatRoom(
- id = chatRoomId,
- userPublicKey = "u".repeat(64),
- subject = "Translation room",
- description = null,
- initialGiftWrapPayloadId = "sdfaer",
- mlsGroupState = "state"
- )
- ),
- schema = schema,
- suggestion = suggestion,
- canSign = true
- ),
- nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
- chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
- frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
- onNavigateBack = {},
- onNavigateToRouteAndPopUpInclusive = {}
- )
- }
- }
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddGroupPostScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddGroupPostScreen.kt
deleted file mode 100644
index 208b9346..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddGroupPostScreen.kt
+++ /dev/null
@@ -1,337 +0,0 @@
-package press.mantra.compose.ui.composable
-
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.imePadding
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.text.input.TextFieldLineLimits
-import androidx.compose.foundation.text.input.rememberTextFieldState
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Draw
-import androidx.compose.material3.BottomAppBar
-import androidx.compose.material3.BottomAppBarDefaults
-import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
-import androidx.compose.material3.ExtendedFloatingActionButton
-import androidx.compose.material3.FloatingActionButtonDefaults
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.OutlinedTextFieldDefaults
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.SnackbarHost
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBar
-import androidx.compose.material3.contentColorFor
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.semantics.disabled
-import androidx.compose.ui.semantics.semantics
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.lifecycle.viewmodel.compose.viewModel
-import press.mantra.compose.database.model.ChatRoom
-import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.repository.ChatRepository
-import press.mantra.compose.repository.FrostSigningRepository
-import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
-import press.mantra.compose.ui.composable.navigation.routes.Route
-import press.mantra.compose.ui.composable.widgets.ErrorState
-import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
-import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
-import press.mantra.compose.ui.composable.widgets.NavigateBackButton
-import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
-import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar
-import press.mantra.compose.ui.composable.widgets.rememberNotifier
-import press.mantra.compose.ui.theme.ConformancePreviews
-import press.mantra.compose.ui.theme.MantraTheme
-import press.mantra.compose.ui.theme.readableContent
-import press.mantra.compose.ui.theme.spacing
-import press.mantra.compose.ui.view.model.AddGroupPostViewModel
-import press.mantra.compose.ui.view.state.AddGroupPostUIState
-import com.vitorpamplona.quartz.nip01Core.core.HexKey
-import mantra.composeapp.generated.resources.Res
-import mantra.composeapp.generated.resources.a_post_needs_something_to_say
-import mantra.composeapp.generated.resources.add_post
-import mantra.composeapp.generated.resources.could_not_ask_the_group_to_sign_this_post
-import mantra.composeapp.generated.resources.propose_post
-import mantra.composeapp.generated.resources.the_group_signs_a_post_so_it_takes_a_quorum
-import mantra.composeapp.generated.resources.this_group_has_no_shared_key_to_sign_a_post
-import mantra.composeapp.generated.resources.what_should_the_group_say
-import org.jetbrains.compose.resources.stringResource
-
-/**
- * The draft of something the group would say, in its own name.
- *
- * Not a message in the room: this is signed by the group's key and read by anybody,
- * which is why the byline over the draft is the group's rather than the writer's.
- * Whoever types it is proposing words for the group to put its signature to, and
- * the words become a post -- attributed to the group and to nobody in particular --
- * only once a quorum has signed.
- *
- * The byline is the whole of the design here. Every other composer in this app puts
- * the author's name over the draft because the author is the person typing; getting
- * that wrong on this screen would have a member writing what looks like their own
- * note and finding the group had said it.
- */
-@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
-@Composable
-fun AddGroupPostScreen(
- activeUserPublicKey: HexKey,
- chatRoomId: String,
- relayHint: String?,
- initialAddGroupPostUIState: AddGroupPostUIState = AddGroupPostUIState.Loading,
- chatRepository: ChatRepository,
- frostSigningRepository: FrostSigningRepository,
- onNavigateToRouteAndPopUpInclusive: (Route) -> Unit,
- onNavigateBack: () -> Unit,
-) {
- val addGroupPostViewModel: AddGroupPostViewModel = viewModel(
- factory = AddGroupPostViewModel.factory(
- chatRoomId = chatRoomId,
- relayHint = relayHint,
- initialAddGroupPostUIState = initialAddGroupPostUIState,
- activeUserPublicKey = activeUserPublicKey,
- chatRepository = chatRepository,
- frostSigningRepository = frostSigningRepository
- )
- )
-
- // Read out here rather than in the click handler: both are composable and an
- // onClick lambda is not. The scope is the caller's so a message survives this
- // screen being replaced by the signing session.
- val notify = rememberNotifier(rememberCoroutineScope())
- val couldNotPropose = stringResource(Res.string.could_not_ask_the_group_to_sign_this_post)
- val nothingToSay = stringResource(Res.string.a_post_needs_something_to_say)
-
- ScreenStateTransition(addGroupPostViewModel.addGroupPostUIState) { uiState ->
- when (val addGroupPostUIState = uiState) {
- is AddGroupPostUIState.Error -> {
- // Nothing to retry: the room came from a navigation argument, and
- // reading it again with the same one fails the same way.
- ErrorState(message = addGroupPostUIState.message, onRetry = null)
- }
-
- is AddGroupPostUIState.Loaded -> {
- val noteFieldState = rememberTextFieldState()
- val canSign = addGroupPostUIState.canSign
-
- // M3 gives a FAB no `enabled`, so borrow the disabled colours every
- // other button in the app uses rather than inventing a shade here.
- val buttonColors = ButtonDefaults.buttonColors()
-
- // imePadding: this screen is a text field the size of the screen,
- // and without it the software keyboard covers what is being typed.
- Scaffold(
- snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
- modifier = Modifier.imePadding(),
- topBar = {
- TopAppBar(
- title = { Text(text = stringResource(Res.string.add_post)) },
- navigationIcon = { NavigateBackButton(onNavigateBack) },
- )
- },
- bottomBar = {
- BottomAppBar(
- actions = {},
- floatingActionButton = {
- ExtendedFloatingActionButton(
- modifier = if (canSign) {
- Modifier
- } else {
- // Looking unavailable is not being unavailable.
- Modifier.semantics { disabled() }
- },
- containerColor = if (canSign) {
- FloatingActionButtonDefaults.containerColor
- } else {
- buttonColors.disabledContainerColor
- },
- contentColor = if (canSign) {
- contentColorFor(FloatingActionButtonDefaults.containerColor)
- } else {
- buttonColors.disabledContentColor
- },
- onClick = {
- if (!canSign) return@ExtendedFloatingActionButton
-
- addGroupPostViewModel.proposePost(
- localChatRoom = addGroupPostUIState.localChatRoom,
- noteField = noteFieldState,
- onSuccess = { sessionId ->
- // Onto the session rather than back
- // to the group. Nothing has been
- // posted yet -- the post appears
- // when a quorum signs -- so landing
- // on the list it is not in would
- // read as a failure.
- onNavigateToRouteAndPopUpInclusive.invoke(
- FrostSigningRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- sessionId = sessionId
- )
- )
- },
- // Both stay on the draft. One is an
- // empty note and one is a failure to
- // propose, and neither is a reason to
- // throw away what was written.
- onEmpty = { notify(nothingToSay) },
- onFailure = { notify(couldNotPropose) }
- )
- }
- ) {
- Icon(Icons.Default.Draw, contentDescription = "Propose post")
- Text(stringResource(Res.string.propose_post))
- }
- }
- )
- }
- ) { innerPadding ->
- Column(
- modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize(),
- verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- // Whose name this goes out in, said before a word is typed.
- // It is the group's, and it is the one thing about this
- // screen a member could reasonably get wrong.
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically
- ) {
- ProfileAvatar(
- size = MaterialTheme.spacing.space500,
- publicKey = chatRoomId,
- picture = addGroupPostUIState.groupNostrProfile?.picture(),
- name = addGroupPostUIState.groupNostrProfile?.name()
- )
-
- Spacer(modifier = Modifier.width(MaterialTheme.spacing.itemGap))
-
- Text(
- text = addGroupPostUIState.groupNostrProfile?.name()
- ?: addGroupPostUIState.localChatRoom.chatRoom.subject
- ?: chatRoomId.take(16),
- style = MaterialTheme.typography.titleSmall,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- }
-
- Text(
- text = stringResource(
- Res.string.the_group_signs_a_post_so_it_takes_a_quorum
- ),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- textAlign = TextAlign.Center
- )
-
- if (!canSign) {
- Text(
- text = stringResource(
- Res.string.this_group_has_no_shared_key_to_sign_a_post
- ),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.error,
- textAlign = TextAlign.Center
- )
- }
-
- OutlinedTextField(
- modifier = Modifier.fillMaxWidth().weight(1f)
- .background(BottomAppBarDefaults.containerColor),
- state = noteFieldState,
- // The draft grows into the screen rather than scrolling
- // inside three lines: a post is the one thing here
- // somebody writes at length, and a composer that hides
- // what was written two sentences ago is a composer
- // nobody proofreads in.
- lineLimits = TextFieldLineLimits.MultiLine(),
- colors = OutlinedTextFieldDefaults.colors(
- focusedBorderColor = Color.Transparent,
- unfocusedBorderColor = Color.Transparent,
- disabledBorderColor = Color.Transparent
- ),
- placeholder = {
- Text(stringResource(Res.string.what_should_the_group_say))
- }
- )
- }
- }
- }
-
- AddGroupPostUIState.Loading -> {
- Column(
- modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.screenMargin),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap)
- ) {
- Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap))
-
- Text(
- text = stringResource(Res.string.add_post),
- style = MaterialTheme.typography.bodyLarge,
- textAlign = TextAlign.Center
- )
-
- LoadingDataIndicator(fillScreen = false)
- }
- }
- }
- }
-
- LaunchedEffect(true) {
- if (initialAddGroupPostUIState == AddGroupPostUIState.Loading) {
- addGroupPostViewModel.initiateAddGroupPost()
- }
- }
-}
-
-@ConformancePreviews
-@Composable
-private fun AddGroupPostScreenPreview() {
- MantraTheme {
- Surface(modifier = Modifier.fillMaxSize()) {
- AddGroupPostScreen(
- activeUserPublicKey = "",
- chatRoomId = "publicKey",
- relayHint = null,
- initialAddGroupPostUIState = AddGroupPostUIState.Loaded(
- localChatRoom = LocalChatRoom(
- chatRoom = ChatRoom(
- id = "publicKey",
- userPublicKey = "",
- subject = "Translation room",
- description = "A group translating hard books.",
- initialGiftWrapPayloadId = "sdfaer",
- mlsGroupState = "state"
- ),
- ),
- // A group that can sign, so the composer renders in the state a
- // member actually meets it in rather than greyed out.
- canSign = true
- ),
- chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
- frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
- onNavigateToRouteAndPopUpInclusive = {},
- onNavigateBack = {},
- )
- }
- }
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt
index cfc9e191..411a5dcc 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt
@@ -81,13 +81,12 @@ import mantra.composeapp.generated.resources.signed_by_the_group_on
import mantra.composeapp.generated.resources.that_is_not_a_relay_address
import mantra.composeapp.generated.resources.that_relay_is_already_in_this_list
import mantra.composeapp.generated.resources.the_signed_event
-import mantra.composeapp.generated.resources.where_the_group_has_said_it_lives_is_filled_in
+import mantra.composeapp.generated.resources.the_relays_this_app_publishes_to_are_filled_in
import org.jetbrains.compose.resources.stringResource
import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.GroupSignedEvent
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.network.relays.EventPublishTransport
-import press.mantra.compose.nostr.GroupRelayList
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.text.ProposedEvent
import press.mantra.compose.ui.composable.widgets.Decorative
@@ -114,18 +113,19 @@ import kotlin.time.Instant
* One event the group signed, and the relays to send it to.
*
* The group's screen shows what the group has signed; this is where one of those
- * leaves the device. The list starts at where the group has said it lives and the
- * member can change it, the button sends to every relay in it at once, and each
- * row then says what its relay answered -- accepted, rejected in the relay's own
+ * leaves the device. The list starts at the app's own publish set and the member
+ * can change it, the button sends to every relay in it at once, and each row
+ * then says what its relay answered -- accepted, rejected in the relay's own
* words, or nothing. The raw JSON is at the bottom for anyone who would rather
* hand it to some other tool.
*
- * **The button sends rather than proposes.** Every other screen off the identity
- * block opens a signing session, because what it makes is a statement by the
- * group. This one moves a statement the group has already made, which any member
- * holding it may do: the signature is the group's, and no share of the key is
- * needed to repeat it. See `BroadcastGroupSignedEventViewModel` for why the send
- * goes to the pool directly rather than through the app's broadcast queue.
+ * **The button sends rather than proposes.** Every screen that opens a signing
+ * session makes a statement by the group. This one moves a statement the group
+ * has already made, which any member holding it may do: the signature is the
+ * group's, and no share of the key is needed to repeat it. See
+ * `BroadcastGroupSignedEventViewModel` for why the send goes to the pool
+ * directly rather than through the app's broadcast queue -- and for the fact
+ * that, at present, nothing in the app opens this screen.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -180,13 +180,7 @@ fun BroadcastGroupSignedEventScreen(
// test -- fills in the same as the app does. Once only, so a list
// the member emptied stays empty.
LaunchedEffect(broadcastGroupSignedEventUIState) {
- broadcastGroupSignedEventViewModel.seedRelays(
- relayLists = broadcastGroupSignedEventUIState.relayLists,
- kind = signedEvent.kind,
- tags = signedEvent.tags,
- content = signedEvent.content,
- schemas = broadcastGroupSignedEventUIState.schemas,
- )
+ broadcastGroupSignedEventViewModel.seedRelays(kind = signedEvent.kind)
}
// A refusal is about what is in the field, so it goes the moment
@@ -317,7 +311,7 @@ fun BroadcastGroupSignedEventScreen(
Text(
text = stringResource(
- Res.string.where_the_group_has_said_it_lives_is_filled_in
+ Res.string.the_relays_this_app_publishes_to_are_filled_in
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
@@ -605,10 +599,6 @@ private fun BroadcastGroupSignedEventScreenPreview() {
signature = "f".repeat(128),
createdAt = Instant.fromEpochSeconds(1_700_000_000)
),
- // Nothing agreed, so the list fills in from the app's own
- // publish set -- the state a group meets this screen in
- // before it has edited its relays.
- relayLists = GroupRelayList.allAmong(emptyList(), chatRoomId),
),
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
publishTransport = object : EventPublishTransport {
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt
index c6475bf2..37a3ae27 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt
@@ -16,14 +16,8 @@ import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.AccountTree
import androidx.compose.material.icons.filled.Autorenew
import androidx.compose.material.icons.filled.ChevronRight
-import androidx.compose.material.icons.filled.Checklist
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.DeleteForever
-import androidx.compose.material.icons.filled.Badge
-import androidx.compose.material.icons.filled.Ballot
-import androidx.compose.material.icons.filled.Campaign
-import androidx.compose.material.icons.filled.DataObject
-import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.Draw
import androidx.compose.material.icons.filled.LibraryBooks
import androidx.compose.material.icons.filled.PersonAdd
@@ -55,7 +49,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
@@ -76,15 +69,8 @@ import press.mantra.compose.repository.NostrRepository
import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute
import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute
-import press.mantra.compose.ui.composable.navigation.routes.ProposeGroupEventRoute
-import press.mantra.compose.ui.composable.navigation.routes.GroupNostrProfileRoute
-import press.mantra.compose.ui.composable.navigation.routes.GroupRelaysRoute
-import press.mantra.compose.ui.composable.navigation.routes.GroupPostsRoute
-import press.mantra.compose.ui.composable.navigation.routes.GroupCuratedSchemasRoute
-import press.mantra.compose.ui.composable.navigation.routes.GroupCuratedEntriesRoute
import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute
import press.mantra.compose.ui.composable.navigation.routes.SearchMemberToAddToChatRoomRoute
-import press.mantra.compose.extensions.inComparableGroups
import press.mantra.compose.extensions.toFormattedTimeAndDateString
import press.mantra.compose.ui.composable.widgets.Decorative
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
@@ -95,31 +81,11 @@ import press.mantra.compose.ui.theme.MantraTheme
import press.mantra.compose.ui.view.model.ChatRoomDetailViewModel
import press.mantra.compose.ui.view.state.ChatRoomDetailUIState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
-import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
-import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute
import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute
import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute
import press.mantra.compose.ui.theme.spacing
import mantra.composeapp.generated.resources.run_by_members
-import mantra.composeapp.generated.resources.nostr_profile
-import mantra.composeapp.generated.resources.relays
-import mantra.composeapp.generated.resources.posts
-import mantra.composeapp.generated.resources.curated_entries
-import mantra.composeapp.generated.resources.curated_schemas
-import mantra.composeapp.generated.resources.not_described_yet
-import mantra.composeapp.generated.resources.n_of_m_relay_lists_agreed
-import mantra.composeapp.generated.resources.no_posts_yet
-import mantra.composeapp.generated.resources.one_post
-import mantra.composeapp.generated.resources.n_posts
-import mantra.composeapp.generated.resources.no_schemas_yet
-import mantra.composeapp.generated.resources.one_schema
-import mantra.composeapp.generated.resources.n_schemas
-import mantra.composeapp.generated.resources.no_entries_yet
-import mantra.composeapp.generated.resources.one_entry
-import mantra.composeapp.generated.resources.n_entries
-import mantra.composeapp.generated.resources.propose_event
-import mantra.composeapp.generated.resources.no_name_on_this_profile
import mantra.composeapp.generated.resources.parent_group
import mantra.composeapp.generated.resources.a_subgroup
import mantra.composeapp.generated.resources.created_you_are_not_a_member
@@ -131,8 +97,6 @@ import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdmins
import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRoute
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute
import press.mantra.compose.managers.SubgroupManager
-import press.mantra.compose.nostr.GroupNostrProfile
-import press.mantra.compose.nostr.GroupRelaySet
import mantra.composeapp.generated.resources.Res
import org.jetbrains.compose.resources.stringResource
import mantra.composeapp.generated.resources.add_artifact_to_library
@@ -353,215 +317,6 @@ fun ChatRoomDetailScreen(
HorizontalDivider()
}
- // Under the room's identity and above its work, because
- // that is what it is: what the rest of nostr reads for
- // the key the signing key row names. The room's subject
- // and description above are this device's names for it;
- // this is the group's own, signed by the group.
- //
- // One row per kind of statement, each opening the screen
- // that shows it, rather than the statements themselves.
- // Five sections of cards, every card with a button under
- // it, made this screen as long as everything the group
- // had ever said, with its members and its subgroups
- // somewhere underneath. What a member opening it wants
- // first is the shape of that -- a name and a few counts
- // -- not every word of it. The row carries the shape;
- // the screen behind it carries the rest, with the same
- // broadcasts and the same gated ways in the sections had.
- //
- // Marmot rooms only, and absent rather than empty in a
- // NIP-17 one. A NIP-17 room's id is not a key it can
- // sign as, so it has no nostr identity at all -- and a
- // row saying it has not described one yet would promise
- // something that is never coming.
- if (chatRoomDetailUIState.localChatRoom.chatRoom.mlsGroupState != null) {
- item {
- SignedWorkRow(
- icon = Icons.Default.Badge,
- headline = stringResource(Res.string.nostr_profile),
- // The name the group gave itself and its
- // address where it claims one: the two
- // things a reader checks a profile for.
- // Named rather than falling back to the
- // pubkey, for the reason the card gives.
- supporting = chatRoomDetailUIState.groupNostrProfile
- ?.let { profile ->
- listOfNotNull(
- profile.name()
- ?: stringResource(Res.string.no_name_on_this_profile),
- profile.nip05()
- ).joinToString(" · ")
- }
- ?: stringResource(Res.string.not_described_yet),
- onOpen = {
- onNavigateToRoute.invoke(
- GroupNostrProfileRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = relayHint
- )
- )
- }
- )
- }
-
- // Directly under the profile, because between them
- // they are the whole of the group's account of
- // itself on nostr: who it says it is, and where it
- // says it can be found.
- item {
- SignedWorkRow(
- icon = Icons.Default.Dns,
- headline = stringResource(Res.string.relays),
- // How many of the four lists the group has
- // agreed, counted against the sets rather
- // than the entries: the entries come back
- // empty in a room that has none of this to
- // say, and "0 of 0" would say nothing.
- supporting = stringResource(
- Res.string.n_of_m_relay_lists_agreed,
- chatRoomDetailUIState.groupRelayLists.count { it.isAgreed },
- GroupRelaySet.entries.size
- ),
- onOpen = {
- onNavigateToRoute.invoke(
- GroupRelaysRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = relayHint
- )
- )
- }
- )
- }
-
- // Under the relay lists, because these are what the
- // lists are *for*: the profile says who the group
- // is, the relays say where to find it, and these are
- // what a reader would find there. Still above the
- // library, which is the group's work rather than its
- // voice.
- item {
- SignedWorkRow(
- icon = Icons.Default.Campaign,
- headline = stringResource(Res.string.posts),
- supporting = when (val count = chatRoomDetailUIState.groupPosts.size) {
- 0 -> stringResource(Res.string.no_posts_yet)
- 1 -> stringResource(Res.string.one_post)
- else -> stringResource(Res.string.n_posts, count)
- },
- onOpen = {
- onNavigateToRoute.invoke(
- GroupPostsRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = relayHint
- )
- )
- }
- )
- }
-
- // Under the posts, because a schema is the other
- // thing a group publishes for strangers to answer:
- // a post is the group speaking, a schema is the
- // group asking -- for entries to a list it will
- // then curate, by quorum, under the same key. What
- // it curated follows, in the row below.
- item {
- SignedWorkRow(
- icon = Icons.Default.Ballot,
- headline = stringResource(Res.string.curated_schemas),
- supporting = when (val count = chatRoomDetailUIState.groupCuratedSchemas.size) {
- 0 -> stringResource(Res.string.no_schemas_yet)
- 1 -> stringResource(Res.string.one_schema)
- else -> stringResource(Res.string.n_schemas, count)
- },
- onOpen = {
- onNavigateToRoute.invoke(
- GroupCuratedSchemasRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = relayHint
- )
- )
- }
- )
- }
-
- // Under the schemas, because these are what a
- // schema is *for*: the schema asks, strangers
- // answer on the queue, and these are the answers
- // the group put its key to. They close the
- // identity block because everything in it is
- // signed by that key and read by people who were
- // never in the room, and an accepted entry is the
- // last thing in that chain.
- item {
- SignedWorkRow(
- icon = Icons.Default.Checklist,
- headline = stringResource(Res.string.curated_entries),
- supporting = when (val count = chatRoomDetailUIState.groupCuratedEntries.size) {
- 0 -> stringResource(Res.string.no_entries_yet)
- 1 -> stringResource(Res.string.one_entry)
- else -> stringResource(Res.string.n_entries, count)
- },
- onOpen = {
- onNavigateToRoute.invoke(
- GroupCuratedEntriesRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = relayHint
- )
- )
- }
- )
- }
-
- // The untyped way into everything above it. Each
- // row opens a screen with a form that builds one
- // kind of event; this takes any of those kinds as
- // JSON, pasted from wherever it was made, and it
- // lands on whichever screen is its kind's once the
- // quorum signs -- the same reading, off the same
- // signed events. Last in the block because it is
- // about the block rather than about any one row of
- // it, and behind the same gate for the same reason.
- if (chatRoomDetailUIState.canEditNostrProfile) {
- item {
- TextButton(
- onClick = {
- onNavigateToRoute.invoke(
- ProposeGroupEventRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = relayHint
- )
- )
- }
- ) {
- Icon(
- Icons.Default.DataObject,
- contentDescription = Decorative
- )
-
- Spacer(
- modifier = Modifier.width(
- MaterialTheme.spacing.space125
- )
- )
-
- Text(stringResource(Res.string.propose_event))
- }
- }
- }
-
- item {
- HorizontalDivider()
- }
- }
-
item {
// Artifacts
Text(
@@ -1290,44 +1045,20 @@ internal fun GroupKeyStateSheetContent(
}
/**
- * One row of the identity block: a kind of statement the group has signed, a
- * line summarising it, and the way to the screen where it is read in full.
+ * A long hex value in groups of eight, which is what makes it readable and what
+ * lets it wrap at all.
*
- * The same shape as the signing key and parent group rows above it -- a card
- * with a leading icon and a trailing chevron -- because it is the same kind of
- * thing: a fact about the group, opened by tapping. The icons are decorative
- * because the headline beside each says what the row is, and the chevron says
- * only what every card on this screen already says by being one.
+ * Both halves matter. A 64-character key is compared against another member's
+ * screen a character at a time, and the eye loses its place in an undivided run
+ * of hex -- the same reason a fingerprint or an account number is grouped.
+ *
+ * And a run of hex has no space in it, so Compose has nowhere to break the line:
+ * it lays the whole thing out on one and lets it run off the side of the sheet,
+ * where the end of the key cannot be read at all. The spaces are the break
+ * opportunities. Nothing is copied from these -- the copy button takes the
+ * signed event, which is not grouped -- so the display can be shaped for reading
+ * without a paste losing the value.
*/
-@Composable
-private fun SignedWorkRow(
- icon: ImageVector,
- headline: String,
- supporting: String,
- onOpen: () -> Unit
-) {
- Card(modifier = Modifier.fillMaxWidth(), onClick = onOpen) {
- ListItem(
- leadingContent = {
- Icon(icon, contentDescription = Decorative)
- },
- trailingContent = {
- Icon(Icons.Default.ChevronRight, contentDescription = Decorative)
- },
- headlineContent = {
- Text(text = headline)
- },
- supportingContent = {
- Text(
- text = supporting,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis
- )
- }
- )
- }
-}
-
/**
* One subgroup, as the parent's record and this device's rooms together have it.
*
@@ -1378,6 +1109,8 @@ private fun SubgroupCard(
}
}
+private fun String.inComparableGroups(): String = chunked(8).joinToString(" ")
+
/** One labelled value of a key state, whole and comparable. */
@Composable
private fun GroupKeyStateField(
@@ -1508,10 +1241,7 @@ private fun ChatRoomMessagingScreenPreview() {
subject = "Message title",
description = "Description of a chat room so that members now why they are here.",
initialGiftWrapPayloadId = "sdfaer",
- // A Marmot room, so the nostr profile section renders
- // at all -- it is hidden in a NIP-17 one, which has no
- // key to have an identity on nostr.
- mlsGroupState = "state"
+ mlsGroupState = null
),
localParticipants = listOf(
LocalParticipant(
@@ -1539,29 +1269,7 @@ private fun ChatRoomMessagingScreenPreview() {
derivationPath = "m/9420/0/0",
announcedBy = "d".repeat(64),
announcedAt = Instant.fromEpochSeconds(1_700_000_000)
- ),
- // A group that has described itself, so the section renders
- // with something in it rather than only in its empty state.
- // Built directly rather than read out of a signed event: the
- // reading checks a signature, and there is no real one here.
- groupNostrProfile = GroupNostrProfile(
- signedEvent = GroupSignedEvent(
- id = "e".repeat(64),
- chatRoomId = "publicKey",
- publicKey = "publicKey",
- kind = MetadataEvent.KIND,
- tags = emptyArray(),
- content = "",
- signature = "f".repeat(128),
- createdAt = Instant.fromEpochSeconds(1_700_000_000)
- ),
- metadata = UserMetadata().apply {
- displayName = "Translation collective"
- about = "A group translating hard books into Sesotho."
- nip05 = "group@example.com"
- }
- ),
- canEditNostrProfile = true
+ )
),
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreen.kt
deleted file mode 100644
index 8f7b69c6..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreen.kt
+++ /dev/null
@@ -1,675 +0,0 @@
-package press.mantra.compose.ui.composable
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.CheckCircle
-import androidx.compose.material.icons.filled.Draw
-import androidx.compose.material3.Button
-import androidx.compose.material3.Card
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
-import androidx.compose.material3.Icon
-import androidx.compose.material3.ListItem
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.SnackbarHost
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.material3.TopAppBar
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.saveable.rememberSaveable
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.text.font.FontFamily
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.lifecycle.viewmodel.compose.viewModel
-import com.vitorpamplona.quartz.nip01Core.core.HexKey
-import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
-import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
-import mantra.composeapp.generated.resources.Res
-import mantra.composeapp.generated.resources.accept_this_suggestion
-import mantra.composeapp.generated.resources.ask_again
-import mantra.composeapp.generated.resources.asking_the_list_s_relays
-import mantra.composeapp.generated.resources.curated
-import mantra.composeapp.generated.resources.everything_anyone_has_suggested_to_this_list
-import mantra.composeapp.generated.resources.n_curated
-import mantra.composeapp.generated.resources.n_suggestions
-import mantra.composeapp.generated.resources.nobody_has_suggested_anything_to_this_list_yet
-import mantra.composeapp.generated.resources.one_suggestion
-import mantra.composeapp.generated.resources.read_from_relays
-import mantra.composeapp.generated.resources.revise_the_list_s_entry
-import mantra.composeapp.generated.resources.suggested_by_on
-import mantra.composeapp.generated.resources.suggestions
-import mantra.composeapp.generated.resources.taken_up_by_the_group_into_the_list
-import org.jetbrains.compose.resources.stringResource
-import press.mantra.compose.database.model.ChatRoom
-import press.mantra.compose.database.model.GroupSignedEvent
-import press.mantra.compose.database.model.NostrEvent
-import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.extensions.toFormattedTimeAndDateString
-import press.mantra.compose.nostr.CuratedSuggestion
-import press.mantra.compose.nostr.GroupCuratedSchema
-import press.mantra.compose.nostr.curated.CuratedEntry
-import press.mantra.compose.nostr.curated.CuratedField
-import press.mantra.compose.nostr.curated.CuratedFieldConfig
-import press.mantra.compose.nostr.curated.CuratedFieldType
-import press.mantra.compose.nostr.curated.CuratedSchema
-import press.mantra.compose.nostr.curated.CuratedSchemaEvent
-import press.mantra.compose.nostr.curated.CuratedVisibility
-import press.mantra.compose.repository.ChatRepository
-import press.mantra.compose.repository.NostrRepository
-import press.mantra.compose.ui.composable.navigation.routes.AcceptCuratedSuggestionRoute
-import press.mantra.compose.ui.composable.navigation.routes.Route
-import press.mantra.compose.ui.composable.widgets.Decorative
-import press.mantra.compose.ui.composable.widgets.EmptyState
-import press.mantra.compose.ui.composable.widgets.ErrorState
-import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
-import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
-import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
-import press.mantra.compose.ui.composable.widgets.dialogs.ModalBottomSheet
-import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar
-import press.mantra.compose.ui.composable.widgets.NavigateBackButton
-import press.mantra.compose.ui.theme.ConformancePreviews
-import press.mantra.compose.ui.theme.MantraTheme
-import press.mantra.compose.ui.theme.readableContent
-import press.mantra.compose.ui.theme.spacing
-import press.mantra.compose.ui.view.model.CuratedSuggestionListViewModel
-import press.mantra.compose.ui.view.state.CuratedSuggestionListUIState
-import kotlin.time.Instant
-
-/**
- * Everything anyone has suggested to one list the group curates.
- *
- * The group's screen shows the schema: what the list asks for. This is what it
- * has been answered with -- one row per suggestion, newest first, whoever
- * signed it -- which is the view a member wants before the group takes
- * anything up: to see what is waiting, and what has already been taken.
- *
- * Read from the relays the schema names, and the header says which, because a
- * queue read from the wrong relays and a list nobody has written to look the
- * same from here. A suggestion that does not satisfy the schema is not on this
- * screen at all; see `CuratedSuggestion`.
- *
- * A row opens a sheet with the whole entry, field by field, and the one thing
- * a member can do about it from here: take it into the list, which opens the
- * form that proposes the canonical entry for the group to sign.
- */
-@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
-@Composable
-fun CuratedSuggestionListScreen(
- activeUserPublicKey: HexKey,
- chatRoomId: String,
- relayHint: String?,
- identifier: String,
- initialCuratedSuggestionListUIState: CuratedSuggestionListUIState =
- CuratedSuggestionListUIState.Loading,
- nostrRepository: NostrRepository,
- chatRepository: ChatRepository,
- onNavigateBack: () -> Unit,
- onNavigateToRoute: (Route) -> Unit,
-) {
- val curatedSuggestionListViewModel: CuratedSuggestionListViewModel = viewModel(
- factory = CuratedSuggestionListViewModel.factory(
- chatRoomId = chatRoomId,
- activeUserPublicKey = activeUserPublicKey,
- relayHint = relayHint,
- identifier = identifier,
- initialCuratedSuggestionListUIState = initialCuratedSuggestionListUIState,
- nostrRepository = nostrRepository,
- chatRepository = chatRepository,
- )
- )
-
- LaunchedEffect(true) {
- if (initialCuratedSuggestionListUIState == CuratedSuggestionListUIState.Loading) {
- curatedSuggestionListViewModel.initiate()
- }
- }
-
- Scaffold(
- snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
- topBar = {
- TopAppBar(
- title = {
- Text(
- text = stringResource(Res.string.suggestions),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- },
- navigationIcon = { NavigateBackButton(onNavigateBack) }
- )
- }
- ) { padding ->
- ScreenStateTransition(curatedSuggestionListViewModel.curatedSuggestionListUIState) { uiState ->
- when (val state = uiState) {
- is CuratedSuggestionListUIState.Error -> {
- // Nothing to retry: the room and the identifier came from a
- // navigation argument, and reading them again with the same
- // ones fails the same way.
- ErrorState(
- modifier = Modifier.padding(padding),
- message = state.message,
- onRetry = null
- )
- }
-
- CuratedSuggestionListUIState.Loading -> {
- Column(
- modifier = Modifier.fillMaxWidth().padding(padding).padding(MaterialTheme.spacing.screenMargin),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap)
- ) {
- Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap))
-
- LoadingDataIndicator(fillScreen = false)
- }
- }
-
- is CuratedSuggestionListUIState.Loaded -> {
- // The id rather than the row, so the sheet follows an edit
- // that replaces the row under it, and survives a rotation.
- var openSuggestionId by rememberSaveable { mutableStateOf(null) }
-
- LazyColumn(
- modifier = Modifier.padding(padding).readableContent().fillMaxSize()
- .padding(MaterialTheme.spacing.space250),
- verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
- ) {
- item {
- CuratedQueueHeader(state = state)
- }
-
- if (state.suggestions.isEmpty()) {
- item {
- if (state.isAsking) {
- LoadingDataIndicator(
- fillScreen = false,
- text = stringResource(Res.string.asking_the_list_s_relays)
- )
- } else {
- // Empty is a fact about the relays that were
- // asked, and asking again is the one thing a
- // member can do about it from here.
- EmptyState(
- message = stringResource(
- Res.string.nobody_has_suggested_anything_to_this_list_yet
- ),
- action = {
- TextButton(
- onClick = { curatedSuggestionListViewModel.askAgain() }
- ) {
- Text(stringResource(Res.string.ask_again))
- }
- }
- )
- }
- }
- } else {
- items(
- items = state.suggestions,
- key = { suggestion -> suggestion.id }
- ) { suggestion ->
- CuratedSuggestionCard(
- suggestion = suggestion,
- schema = state.schema.schema,
- onOpen = { openSuggestionId = suggestion.id }
- )
- }
- }
- }
-
- openSuggestionId
- ?.let { id -> state.suggestions.firstOrNull { it.id == id } }
- ?.let { suggestion ->
- CuratedSuggestionSheet(
- suggestion = suggestion,
- schema = state.schema.schema,
- onAccept = {
- openSuggestionId = null
- onNavigateToRoute(
- AcceptCuratedSuggestionRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = relayHint,
- identifier = identifier,
- suggestionId = suggestion.id
- )
- )
- },
- onDismiss = { openSuggestionId = null }
- )
- }
- }
- }
- }
- }
-}
-
-/**
- * What the queue is, in numbers, and where it was read from.
- *
- * The list is named the way its card on the group's screen names it -- the
- * domain where it claims one, else the name -- so a member arriving from that
- * card finds the same word. The counts are the reference client's line: how
- * many are waiting, and how many of those the group has already taken up.
- */
-@Composable
-private fun CuratedQueueHeader(state: CuratedSuggestionListUIState.Loaded) {
- Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)) {
- Text(
- text = state.schema.schema.displayName(),
- style = MaterialTheme.typography.titleLarge,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
-
- Text(
- text = stringResource(Res.string.everything_anyone_has_suggested_to_this_list),
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
-
- Text(
- text = listOfNotNull(
- when (state.suggestions.size) {
- 1 -> stringResource(Res.string.one_suggestion)
- else -> stringResource(Res.string.n_suggestions, state.suggestions.size)
- },
- state.curatedCount.takeIf { it > 0 }?.let { stringResource(Res.string.n_curated, it) }
- ).joinToString(" · "),
- style = MaterialTheme.typography.labelMedium
- )
-
- if (state.relays.isNotEmpty()) {
- Text(
- text = stringResource(
- Res.string.read_from_relays,
- state.relays.joinToString { relay ->
- RelayUrlNormalizer.normalizeOrNull(relay)?.displayUrl() ?: relay
- }
- ),
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- }
-}
-
-/**
- * One suggestion, as a row somebody is scanning.
- *
- * The title, because every list has one; a glance at the fields that say what
- * the entry *is* -- the type, the year, the director -- and not the ones that
- * say where to find it, since a URL on a row is a line of noise and the sheet
- * has them; the body where the list has one; and who suggested it, when. The
- * avatar is the suggester's, tinted from their key like every avatar here, so
- * two suggestions by one stranger read as one stranger.
- *
- * "Curated" on the row is the one thing on it a member might act on: it says
- * the group already took this up, and re-curating it would revise the entry
- * rather than add one.
- */
-@Composable
-private fun CuratedSuggestionCard(
- suggestion: CuratedSuggestion,
- schema: CuratedSchema,
- onOpen: () -> Unit
-) {
- val entry = suggestion.entry
-
- Card(modifier = Modifier.fillMaxWidth(), onClick = onOpen) {
- ListItem(
- leadingContent = {
- ProfileAvatar(publicKey = entry.pubKey, profile = suggestion.suggester)
- },
- trailingContent = if (suggestion.isCurated) {
- { CuratedMark() }
- } else {
- null
- },
- headlineContent = {
- Text(
- text = entry.title,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis
- )
- },
- supportingContent = {
- Column(
- verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)
- ) {
- glanceOf(entry, schema)?.let {
- Text(
- text = it,
- style = MaterialTheme.typography.bodySmall,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis
- )
- }
-
- bodyOf(entry, schema)?.let {
- Text(
- text = it,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis
- )
- }
-
- Text(
- text = stringResource(
- Res.string.suggested_by_on,
- suggestion.suggesterName(),
- suggestion.suggestedAt.toFormattedTimeAndDateString()
- ),
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- }
- )
- }
-}
-
-@Composable
-private fun CuratedMark() {
- Row(verticalAlignment = Alignment.CenterVertically) {
- // The word beside it says it, so the icon says nothing twice.
- Icon(
- Icons.Default.CheckCircle,
- contentDescription = Decorative,
- tint = MaterialTheme.colorScheme.primary
- )
-
- Spacer(modifier = Modifier.width(MaterialTheme.spacing.relatedGap))
-
- Text(
- text = stringResource(Res.string.curated),
- style = MaterialTheme.typography.labelLarge,
- color = MaterialTheme.colorScheme.primary
- )
- }
-}
-
-/**
- * The fields worth a glance on a row, as `Label: value`, or null when the entry
- * has none of them.
- *
- * The fields a form prompts for, less the title (the headline), the body (its
- * own line) and anything that is a link (the sheet). Labelled because a bare
- * "2014 · en" says nothing to somebody who does not know the list's fields,
- * and this screen is driven by a schema it has never seen before. Shared with
- * the group's screen, so an entry is described the same way on both.
- */
-internal fun glanceOf(entry: CuratedEntry, schema: CuratedSchema): String? =
- schema.formFields()
- .filterNot { it.tag() == TITLE_TAG || it.tag() == CuratedFieldConfig.CONTENT_TAG }
- .filterNot { it.type == CuratedFieldType.Url || it.type == CuratedFieldType.Image }
- .mapNotNull { field ->
- entry.values[field.name]?.let { values -> "${field.labelOrName()}: ${values.joinToString()}" }
- }
- .takeIf { it.isNotEmpty() }
- ?.joinToString(" · ")
-
-/** The value of the field that writes to the body, or null. */
-internal fun bodyOf(entry: CuratedEntry, schema: CuratedSchema): String? =
- schema.fields.firstOrNull { it.tag() == CuratedFieldConfig.CONTENT_TAG }?.let { entry.value(it.name) }
-
-private const val TITLE_TAG = "title"
-
-/**
- * The whole of one suggestion, and the way to take it into the list.
- *
- * Every field the entry has a value for, labelled as the schema labels it, so
- * the sheet reads as the form would have -- and then the button, which is the
- * one thing a member can do about a suggestion: open the form that proposes it
- * as the list's own, for the group to sign. The event itself is not shown; it
- * is what the form is built from, and the form shows it as fields rather than
- * as JSON, which is how a member reads it and how the group will sign it.
- */
-@Composable
-private fun CuratedSuggestionSheet(
- suggestion: CuratedSuggestion,
- schema: CuratedSchema,
- onAccept: () -> Unit,
- onDismiss: () -> Unit
-) {
- ModalBottomSheet(onDismiss = onDismiss) {
- CuratedSuggestionSheetContent(suggestion = suggestion, schema = schema, onAccept = onAccept)
- }
-}
-
-/**
- * The sheet's body, apart from the sheet. Split out so it can be rendered and
- * measured on its own, since a bottom sheet is a popup a layout test cannot
- * reach into.
- */
-@Composable
-internal fun CuratedSuggestionSheetContent(
- suggestion: CuratedSuggestion,
- schema: CuratedSchema,
- onAccept: () -> Unit
-) {
- val entry = suggestion.entry
-
- Column(modifier = Modifier.fillMaxWidth()) {
- Text(
- text = entry.title,
- style = MaterialTheme.typography.titleMedium
- )
-
- Spacer(modifier = Modifier.height(MaterialTheme.spacing.relatedGap))
-
- Text(
- text = stringResource(
- Res.string.suggested_by_on,
- suggestion.suggesterName(),
- suggestion.suggestedAt.toFormattedTimeAndDateString()
- ),
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
-
- if (suggestion.isCurated) {
- Spacer(modifier = Modifier.height(MaterialTheme.spacing.relatedGap))
-
- Row(verticalAlignment = Alignment.CenterVertically) {
- Icon(
- Icons.Default.CheckCircle,
- contentDescription = Decorative,
- tint = MaterialTheme.colorScheme.primary
- )
-
- Spacer(modifier = Modifier.width(MaterialTheme.spacing.relatedGap))
-
- Text(
- text = stringResource(Res.string.taken_up_by_the_group_into_the_list),
- style = MaterialTheme.typography.bodyMedium
- )
- }
- }
-
- Spacer(modifier = Modifier.height(MaterialTheme.spacing.sectionGap))
-
- // In the schema's order, which is the order the form asked in, and
- // only the fields the entry answered: a form's empty inputs are not
- // worth a line each here. The title is the heading and is not said twice.
- schema.fields.filterNot { it.tag() == TITLE_TAG }.forEach { field ->
- entry.values[field.name]?.let { values ->
- CuratedSuggestionField(field = field, values = values)
- }
- }
-
- Spacer(modifier = Modifier.height(MaterialTheme.spacing.itemGap))
-
- // The same button either way, worded for what it will do: a suggestion
- // the group already took up is revised in place, since the canonical
- // entry keeps its identifier, and a member should not have to find that
- // out from the queue afterwards.
- Button(
- onClick = onAccept,
- modifier = Modifier.fillMaxWidth()
- ) {
- Icon(Icons.Default.Draw, contentDescription = Decorative)
- Spacer(modifier = Modifier.width(MaterialTheme.spacing.space100))
- Text(
- text = stringResource(
- if (suggestion.isCurated) {
- Res.string.revise_the_list_s_entry
- } else {
- Res.string.accept_this_suggestion
- }
- )
- )
- }
- }
-}
-
-/**
- * One field of an entry: the schema's label over the value, or the values one
- * under another for a field that repeats. Monospaced where the value is a
- * thing rather than prose -- a token, a link -- because those are compared and
- * copied rather than read.
- */
-@Composable
-private fun CuratedSuggestionField(field: CuratedField, values: List) {
- val isMonospaced = when (field.type) {
- CuratedFieldType.Token, CuratedFieldType.Url, CuratedFieldType.Image -> true
- else -> false
- }
-
- Text(
- text = field.labelOrName(),
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
-
- Text(
- text = values.joinToString("\n"),
- style = MaterialTheme.typography.bodyMedium,
- fontFamily = if (isMonospaced) FontFamily.Monospace else null,
- // Stated rather than inherited: the label above is deliberately quieter,
- // and the value is the thing that was come for.
- color = MaterialTheme.colorScheme.onSurface,
- modifier = Modifier.fillMaxWidth()
- )
-
- Spacer(modifier = Modifier.height(MaterialTheme.spacing.itemGap))
-}
-
-@ConformancePreviews
-@Composable
-private fun CuratedSuggestionListScreenPreview() {
- val chatRoomId = "d4c3b2a1".repeat(8)
- val suggester = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860"
-
- val schema = GroupCuratedSchema(
- signedEvent = GroupSignedEvent(
- id = "c".repeat(64),
- chatRoomId = chatRoomId,
- publicKey = chatRoomId,
- kind = CuratedSchemaEvent.KIND,
- tags = emptyArray(),
- content = "",
- signature = "f".repeat(128),
- createdAt = Instant.fromEpochSeconds(1_700_000_000)
- ),
- schema = CuratedSchema(
- identifier = "films",
- title = "Film suggestion",
- name = "Films worth translating",
- description = "Films the group would subtitle.",
- visibility = CuratedVisibility.Public,
- fields = listOf(
- CuratedField.IDENTIFIER,
- CuratedField.TITLE,
- CuratedField("year", CuratedFieldType.Year, isRequired = false, label = "Year"),
- CuratedField("type", CuratedFieldType.Enum, isRequired = true, label = "Type"),
- CuratedField(
- "description",
- CuratedFieldType.LongText,
- isRequired = false,
- label = "Why",
- config = CuratedFieldConfig(tag = CuratedFieldConfig.CONTENT_TAG)
- ),
- ),
- relays = listOf("wss://relay.one.example")
- )
- )
-
- fun suggestion(title: String, createdAt: Long, isCurated: Boolean) = CuratedSuggestion(
- entry = CuratedEntry(
- kind = CuratedSchemaEvent.SUGGESTION_KIND,
- id = createdAt.toString().padStart(64, 'a'),
- pubKey = suggester,
- createdAt = createdAt,
- identifier = title.lowercase().replace(' ', '-'),
- title = title,
- values = mapOf(
- "title" to listOf(title),
- "year" to listOf("2014"),
- "type" to listOf("documentary"),
- "description" to listOf("Follows the early Bitcoin community from 2011 onward."),
- )
- ),
- event = NostrEvent(
- id = createdAt.toString().padStart(64, 'a'),
- pubKey = suggester,
- kind = CuratedSchemaEvent.SUGGESTION_KIND,
- tags = emptyArray(),
- content = "",
- sig = "b".repeat(128),
- createdAt = Instant.fromEpochSeconds(createdAt)
- ),
- suggester = null,
- isCurated = isCurated
- )
-
- MantraTheme {
- Surface(modifier = Modifier.fillMaxSize()) {
- CuratedSuggestionListScreen(
- activeUserPublicKey = "u".repeat(64),
- chatRoomId = chatRoomId,
- relayHint = null,
- identifier = "films",
- initialCuratedSuggestionListUIState = CuratedSuggestionListUIState.Loaded(
- localChatRoom = LocalChatRoom(
- chatRoom = ChatRoom(
- id = chatRoomId,
- userPublicKey = "u".repeat(64),
- subject = "Translation room",
- description = null,
- initialGiftWrapPayloadId = "sdfaer",
- mlsGroupState = "state"
- )
- ),
- schema = schema,
- relays = listOf("wss://relay.one.example"),
- suggestions = listOf(
- suggestion("The Rise and Rise of Bitcoin", 1_700_000_900, isCurated = true),
- suggestion("Magic Money", 1_700_000_000, isCurated = false),
- )
- ),
- nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
- chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
- onNavigateBack = {},
- onNavigateToRoute = {}
- )
- }
- }
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreen.kt
deleted file mode 100644
index 6a113f52..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreen.kt
+++ /dev/null
@@ -1,1525 +0,0 @@
-package press.mantra.compose.ui.composable
-
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.ExperimentalLayoutApi
-import androidx.compose.foundation.layout.FlowRow
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.imePadding
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
-import androidx.compose.foundation.lazy.itemsIndexed
-import androidx.compose.foundation.text.input.TextFieldLineLimits
-import androidx.compose.foundation.text.input.TextFieldState
-import androidx.compose.foundation.text.input.clearText
-import androidx.compose.foundation.text.input.rememberTextFieldState
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.Label
-import androidx.compose.material.icons.automirrored.filled.Notes
-import androidx.compose.material.icons.automirrored.filled.Rule
-import androidx.compose.material.icons.automirrored.filled.ShortText
-import androidx.compose.material.icons.filled.Add
-import androidx.compose.material.icons.filled.Ballot
-import androidx.compose.material.icons.filled.Bookmark
-import androidx.compose.material.icons.filled.Checklist
-import androidx.compose.material.icons.filled.Delete
-import androidx.compose.material.icons.filled.Dns
-import androidx.compose.material.icons.filled.Draw
-import androidx.compose.material.icons.filled.Image
-import androidx.compose.material.icons.filled.Language
-import androidx.compose.material.icons.filled.Lightbulb
-import androidx.compose.material.icons.filled.Numbers
-import androidx.compose.material.icons.filled.PersonAdd
-import androidx.compose.material.icons.filled.Sell
-import androidx.compose.material.icons.filled.Tag
-import androidx.compose.material.icons.filled.Title
-import androidx.compose.material3.BottomAppBar
-import androidx.compose.material3.BottomAppBarDefaults
-import androidx.compose.material3.Button
-import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.Card
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
-import androidx.compose.material3.ExtendedFloatingActionButton
-import androidx.compose.material3.FilterChip
-import androidx.compose.material3.FloatingActionButtonDefaults
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.ListItem
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.OutlinedTextFieldDefaults
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.SegmentedButton
-import androidx.compose.material3.SegmentedButtonDefaults
-import androidx.compose.material3.SingleChoiceSegmentedButtonRow
-import androidx.compose.material3.SnackbarHost
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Switch
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.material3.TopAppBar
-import androidx.compose.material3.contentColorFor
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.runtime.saveable.rememberSaveable
-import androidx.compose.runtime.setValue
-import androidx.compose.runtime.snapshotFlow
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.semantics.contentDescription
-import androidx.compose.ui.semantics.disabled
-import androidx.compose.ui.semantics.semantics
-import androidx.compose.ui.text.font.FontFamily
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.lifecycle.viewmodel.compose.viewModel
-import press.mantra.compose.database.model.ChatRoom
-import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.extensions.hexToNpubHrp
-import press.mantra.compose.nostr.curated.CuratedField
-import press.mantra.compose.nostr.curated.CuratedFieldType
-import press.mantra.compose.nostr.curated.CuratedSchemaEvent
-import press.mantra.compose.nostr.curated.CuratedSchemaProblem
-import press.mantra.compose.nostr.curated.CuratedVisibility
-import press.mantra.compose.repository.ChatRepository
-import press.mantra.compose.repository.FrostSigningRepository
-import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
-import press.mantra.compose.ui.composable.navigation.routes.Route
-import press.mantra.compose.ui.composable.widgets.Decorative
-import press.mantra.compose.ui.composable.widgets.ErrorState
-import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
-import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
-import press.mantra.compose.ui.composable.widgets.NavigateBackButton
-import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
-import press.mantra.compose.ui.composable.widgets.dialogs.ModalBottomSheet
-import press.mantra.compose.ui.composable.widgets.rememberNotifier
-import press.mantra.compose.ui.theme.ConformancePreviews
-import press.mantra.compose.ui.theme.MantraTheme
-import press.mantra.compose.ui.theme.readableContent
-import press.mantra.compose.ui.theme.spacing
-import press.mantra.compose.ui.view.model.EditGroupCuratedSchemaViewModel
-import press.mantra.compose.ui.view.model.EditGroupCuratedSchemaViewModel.AddRelayFailure
-import press.mantra.compose.ui.view.model.EditGroupCuratedSchemaViewModel.AddSuggesterFailure
-import press.mantra.compose.ui.view.model.EditGroupCuratedSchemaViewModel.FieldEdit
-import press.mantra.compose.ui.view.model.EditGroupCuratedSchemaViewModel.FieldProblem
-import press.mantra.compose.ui.view.state.EditGroupCuratedSchemaUIState
-import com.vitorpamplona.quartz.nip01Core.core.HexKey
-import mantra.composeapp.generated.resources.Res
-import mantra.composeapp.generated.resources.a_description_is_required
-import mantra.composeapp.generated.resources.a_domain_replaces_the_name_wherever_the_list_is_shown
-import mantra.composeapp.generated.resources.a_field_name_is_one_word
-import mantra.composeapp.generated.resources.a_field_named_that_already_exists
-import mantra.composeapp.generated.resources.a_field_needs_a_name
-import mantra.composeapp.generated.resources.a_name_is_required
-import mantra.composeapp.generated.resources.a_regular_expression_the_whole_value_must_match
-import mantra.composeapp.generated.resources.a_schema_needs_at_least_one_field
-import mantra.composeapp.generated.resources.a_title_is_required
-import mantra.composeapp.generated.resources.add_field
-import mantra.composeapp.generated.resources.add_rule
-import mantra.composeapp.generated.resources.add_schema
-import mantra.composeapp.generated.resources.an_entry_must_have_this_field
-import mantra.composeapp.generated.resources.an_identifier_is_required
-import mantra.composeapp.generated.resources.at_least_one_of
-import mantra.composeapp.generated.resources.could_not_ask_the_group_to_sign_this_schema
-import mantra.composeapp.generated.resources.defaults_to_the_field_name_content_means_the_body
-import mantra.composeapp.generated.resources.derived
-import mantra.composeapp.generated.resources.description
-import mantra.composeapp.generated.resources.domain
-import mantra.composeapp.generated.resources.done
-import mantra.composeapp.generated.resources.edit_field
-import mantra.composeapp.generated.resources.edit_schema
-import mantra.composeapp.generated.resources.eg_1900
-import mantra.composeapp.generated.resources.eg_200
-import mantra.composeapp.generated.resources.eg_2100
-import mantra.composeapp.generated.resources.eg_a_za_z_2_8
-import mantra.composeapp.generated.resources.eg_bitcoin_mov
-import mantra.composeapp.generated.resources.eg_movie_documentary_short
-import mantra.composeapp.generated.resources.eg_bitcoin_mov_suggestion
-import mantra.composeapp.generated.resources.eg_example_com
-import mantra.composeapp.generated.resources.eg_fields_for_a_bitcoin_mov_entry
-import mantra.composeapp.generated.resources.eg_https_example_com_group_png
-import mantra.composeapp.generated.resources.eg_https_youtube_com_watch
-import mantra.composeapp.generated.resources.eg_watch
-import mantra.composeapp.generated.resources.eg_watch_reference_url
-import mantra.composeapp.generated.resources.eg_watchurl
-import mantra.composeapp.generated.resources.eg_wss_relay_example_com
-import mantra.composeapp.generated.resources.field_name
-import mantra.composeapp.generated.resources.field_type_duration
-import mantra.composeapp.generated.resources.field_type_enum
-import mantra.composeapp.generated.resources.field_type_image
-import mantra.composeapp.generated.resources.field_type_longtext
-import mantra.composeapp.generated.resources.field_type_number
-import mantra.composeapp.generated.resources.field_type_text
-import mantra.composeapp.generated.resources.field_type_token
-import mantra.composeapp.generated.resources.field_type_url
-import mantra.composeapp.generated.resources.field_type_year
-import mantra.composeapp.generated.resources.fields
-import mantra.composeapp.generated.resources.filled_in_by_the_app_never_asked_for
-import mantra.composeapp.generated.resources.helper_text_under_the_input
-import mantra.composeapp.generated.resources.hint
-import mantra.composeapp.generated.resources.https_only
-import mantra.composeapp.generated.resources.identifier
-import mantra.composeapp.generated.resources.label
-import mantra.composeapp.generated.resources.marker
-import mantra.composeapp.generated.resources.maximum_characters
-import mantra.composeapp.generated.resources.maximum_value
-import mantra.composeapp.generated.resources.may_appear_more_than_once
-import mantra.composeapp.generated.resources.minimum_value
-import mantra.composeapp.generated.resources.name
-import mantra.composeapp.generated.resources.new_field
-import mantra.composeapp.generated.resources.no_relays_named_so_a_client_will_pick_its_own
-import mantra.composeapp.generated.resources.nothing_has_changed_to_propose
-import mantra.composeapp.generated.resources.npub_or_hex_pubkey
-import mantra.composeapp.generated.resources.one_of_the_relays_is_not_a_relay_address
-import mantra.composeapp.generated.resources.one_per_line_or_separated_by_commas
-import mantra.composeapp.generated.resources.optional
-import mantra.composeapp.generated.resources.options
-import mantra.composeapp.generated.resources.pattern
-import mantra.composeapp.generated.resources.pick_two_or_more_fields_at_least_one_of_which_an_entry_must_have
-import mantra.composeapp.generated.resources.picture_url
-import mantra.composeapp.generated.resources.placeholder
-import mantra.composeapp.generated.resources.propose_schema
-import mantra.composeapp.generated.resources.pubkeys_besides_the_group_that_may_suggest_to_this_list
-import mantra.composeapp.generated.resources.relay_url
-import mantra.composeapp.generated.resources.relays
-import mantra.composeapp.generated.resources.remove_field
-import mantra.composeapp.generated.resources.required
-import mantra.composeapp.generated.resources.say_who_may_suggest_to_this_list
-import mantra.composeapp.generated.resources.suggesters
-import mantra.composeapp.generated.resources.that_is_not_a_domain_name
-import mantra.composeapp.generated.resources.that_is_not_a_pubkey
-import mantra.composeapp.generated.resources.that_is_not_a_relay_address
-import mantra.composeapp.generated.resources.that_pubkey_is_already_a_suggester
-import mantra.composeapp.generated.resources.that_relay_is_already_in_this_list
-import mantra.composeapp.generated.resources.the_description_must_be_at_most_n_characters
-import mantra.composeapp.generated.resources.the_group_signs_a_schema_so_it_takes_a_quorum
-import mantra.composeapp.generated.resources.the_identifier_must_be_at_most_n_characters
-import mantra.composeapp.generated.resources.the_identifier_names_this_list_in_every_reply
-import mantra.composeapp.generated.resources.the_name_must_be_at_most_n_characters
-import mantra.composeapp.generated.resources.the_picture_must_be_an_https_url
-import mantra.composeapp.generated.resources.the_tag_s_third_element_which_tells_fields_sharing_a_tag_apart
-import mantra.composeapp.generated.resources.the_title_must_be_at_most_n_characters
-import mantra.composeapp.generated.resources.this_group_has_no_shared_key_to_sign_a_schema
-import mantra.composeapp.generated.resources.title
-import mantra.composeapp.generated.resources.type
-import mantra.composeapp.generated.resources.visibility
-import mantra.composeapp.generated.resources.visibility_closed
-import mantra.composeapp.generated.resources.visibility_closed_purpose
-import mantra.composeapp.generated.resources.visibility_private
-import mantra.composeapp.generated.resources.visibility_private_purpose
-import mantra.composeapp.generated.resources.visibility_public
-import mantra.composeapp.generated.resources.visibility_public_purpose
-import mantra.composeapp.generated.resources.what_an_entry_in_this_list_may_contain
-import mantra.composeapp.generated.resources.where_suggestions_to_this_list_are_published
-import mantra.composeapp.generated.resources.writes_to_tag
-import org.jetbrains.compose.resources.StringResource
-import org.jetbrains.compose.resources.stringResource
-
-/**
- * The form for a list the group curates: what an entry may contain, who may
- * suggest one, and where suggestions go.
- *
- * One screen for the whole schema, because the schema is one event and one
- * signature. The identity -- identifier, title, name, description, visibility,
- * picture, domain -- is text fields like every other editor's. Under it are the
- * four things a schema has several of: fields, rules saying at least one of some
- * fields must be present, extra suggesters on a closed list, and relays. Each
- * is a list with a row per entry and a way to add one, and a field is edited on
- * a sheet of its own, since a field has a dozen settings and thirteen of them
- * inline would be a screen nobody could find anything on.
- *
- * **The button proposes rather than saves.** The schema goes out as one event
- * for the room's quorum to put its key to, and the screen hands over to the
- * signing session it opened. Nothing about the list has changed by the time this
- * screen closes -- and until a quorum signs, suggesters have nothing to reply to.
- *
- * **An edit keeps the identifier.** The identifier is the coordinate every
- * suggestion to the list replies to, so changing it would not edit the list but
- * start a second one and orphan the first's queue. The field is read-only on an
- * edit and says why; a new list is "Add schema" on the group's screen.
- */
-@OptIn(
- ExperimentalMaterial3ExpressiveApi::class,
- ExperimentalMaterial3Api::class,
- ExperimentalLayoutApi::class
-)
-@Composable
-fun EditGroupCuratedSchemaScreen(
- activeUserPublicKey: HexKey,
- chatRoomId: String,
- relayHint: String?,
- identifier: String?,
- initialEditGroupCuratedSchemaUIState: EditGroupCuratedSchemaUIState =
- EditGroupCuratedSchemaUIState.Loading,
- chatRepository: ChatRepository,
- frostSigningRepository: FrostSigningRepository,
- onNavigateToRouteAndPopUpInclusive: (Route) -> Unit,
- onNavigateBack: () -> Unit,
-) {
- val editGroupCuratedSchemaViewModel: EditGroupCuratedSchemaViewModel = viewModel(
- factory = EditGroupCuratedSchemaViewModel.factory(
- chatRoomId = chatRoomId,
- relayHint = relayHint,
- identifier = identifier,
- initialEditGroupCuratedSchemaUIState = initialEditGroupCuratedSchemaUIState,
- activeUserPublicKey = activeUserPublicKey,
- chatRepository = chatRepository,
- frostSigningRepository = frostSigningRepository
- )
- )
-
- // Read out here rather than in a click handler: both are composable and an
- // onClick lambda is not. The scope is the caller's so a message survives this
- // screen being replaced by the signing session.
- val notify = rememberNotifier(rememberCoroutineScope())
- val couldNotPropose = stringResource(Res.string.could_not_ask_the_group_to_sign_this_schema)
- val nothingChanged = stringResource(Res.string.nothing_has_changed_to_propose)
-
- ScreenStateTransition(editGroupCuratedSchemaViewModel.editGroupCuratedSchemaUIState) { uiState ->
- when (val editGroupCuratedSchemaUIState = uiState) {
- is EditGroupCuratedSchemaUIState.Error -> {
- // Nothing to retry: the room and the identifier came from a
- // navigation argument, and reading them again with the same ones
- // fails the same way.
- ErrorState(message = editGroupCuratedSchemaUIState.message, onRetry = null)
- }
-
- is EditGroupCuratedSchemaUIState.Loaded -> {
- val existing = editGroupCuratedSchemaUIState.existing
- val schema = existing?.schema
- val canSign = editGroupCuratedSchemaUIState.canSign
-
- // Seeded from the schema the group already signed, so the form is
- // an edit of it. `rememberTextFieldState` saves what is typed, which
- // is what survives a rotation with the edits intact.
- val identifierFieldState = rememberTextFieldState(schema?.identifier ?: "")
- val titleFieldState = rememberTextFieldState(schema?.title ?: "")
- val nameFieldState = rememberTextFieldState(schema?.name ?: "")
- val descriptionFieldState = rememberTextFieldState(schema?.description ?: "")
- val pictureFieldState = rememberTextFieldState(schema?.picture ?: "")
- val domainFieldState = rememberTextFieldState(schema?.domain ?: "")
- val relayFieldState = rememberTextFieldState()
- val suggesterFieldState = rememberTextFieldState()
-
- // The lists come from the state rather than from whatever loaded
- // it, so a screen handed a loaded state -- a preview, a layout
- // test -- fills in the same as the app does. Seeding runs once.
- LaunchedEffect(editGroupCuratedSchemaUIState) {
- editGroupCuratedSchemaViewModel.seedWorkingCopy(editGroupCuratedSchemaUIState)
- }
-
- // A refusal is about what is in the field, so it goes the moment
- // the field changes rather than sitting under a value that has
- // since been corrected.
- LaunchedEffect(relayFieldState) {
- snapshotFlow { relayFieldState.text.toString() }
- .collect { editGroupCuratedSchemaViewModel.clearAddRelayFailure() }
- }
- LaunchedEffect(suggesterFieldState) {
- snapshotFlow { suggesterFieldState.text.toString() }
- .collect { editGroupCuratedSchemaViewModel.clearAddSuggesterFailure() }
- }
-
- // M3 gives a FAB no `enabled`, so borrow the disabled colours every
- // other button in the app uses rather than inventing a shade here.
- val buttonColors = ButtonDefaults.buttonColors()
-
- Scaffold(
- snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
- modifier = Modifier.imePadding(),
- topBar = {
- TopAppBar(
- title = {
- editGroupCuratedSchemaUIState.localChatRoom.RenderChatRoomTitleText()
- },
- navigationIcon = { NavigateBackButton(onNavigateBack) },
- )
- },
- bottomBar = {
- BottomAppBar(
- actions = {},
- floatingActionButton = {
- ExtendedFloatingActionButton(
- modifier = if (canSign) {
- Modifier
- } else {
- // Looking unavailable is not being unavailable.
- Modifier.semantics { disabled() }
- },
- containerColor = if (canSign) {
- FloatingActionButtonDefaults.containerColor
- } else {
- buttonColors.disabledContainerColor
- },
- contentColor = if (canSign) {
- contentColorFor(FloatingActionButtonDefaults.containerColor)
- } else {
- buttonColors.disabledContentColor
- },
- onClick = {
- if (!canSign) return@ExtendedFloatingActionButton
-
- editGroupCuratedSchemaViewModel.proposeSchema(
- localChatRoom =
- editGroupCuratedSchemaUIState.localChatRoom,
- existing = existing,
- identifier = identifierFieldState.text.toString(),
- title = titleFieldState.text.toString(),
- name = nameFieldState.text.toString(),
- description = descriptionFieldState.text.toString(),
- picture = pictureFieldState.text.toString(),
- domain = domainFieldState.text.toString(),
- onSuccess = { sessionId ->
- // Onto the session rather than back
- // to the group. The schema has not
- // changed yet -- it changes when a
- // quorum signs -- so landing on a
- // group still showing the old one
- // would read as a failure.
- onNavigateToRouteAndPopUpInclusive.invoke(
- FrostSigningRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- sessionId = sessionId
- )
- )
- },
- // The reasons are drawn on the form
- // itself, where the fields they are
- // about are; a snackbar would name a
- // problem and scroll away from it.
- onInvalid = {},
- // Both stay on the form with the edits
- // still in it. One is a question and
- // one is a failure, and neither is a
- // reason to throw away the typing.
- onNothingToPropose = { notify(nothingChanged) },
- onFailure = { notify(couldNotPropose) }
- )
- }
- ) {
- Icon(Icons.Default.Draw, contentDescription = "Propose schema")
- Text(stringResource(Res.string.propose_schema))
- }
- }
- )
- }
- ) { innerPadding ->
- // One lazy list for the whole form rather than a scrolling
- // column, because the form has four lists in it and a lazy
- // list cannot sit inside a scrolling column. The text fields
- // are declared above so that scrolling one off the screen does
- // not lose what was typed into it.
- LazyColumn(
- modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize(),
- contentPadding = PaddingValues(
- horizontal = MaterialTheme.spacing.screenMargin,
- vertical = MaterialTheme.spacing.itemGap
- ),
- verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- item {
- Text(
- text = stringResource(Res.string.what_an_entry_in_this_list_may_contain),
- style = MaterialTheme.typography.titleSmall,
- textAlign = TextAlign.Center
- )
- }
-
- item {
- Text(
- text = stringResource(
- Res.string.the_group_signs_a_schema_so_it_takes_a_quorum
- ),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- textAlign = TextAlign.Center
- )
- }
-
- if (!canSign) {
- item {
- Text(
- text = stringResource(
- Res.string.this_group_has_no_shared_key_to_sign_a_schema
- ),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.error,
- textAlign = TextAlign.Center
- )
- }
- }
-
- // Named rather than silent, and all of them at once: a
- // button that does nothing for a schema it refuses is
- // indistinguishable from a missed tap, and one reason per
- // press would be a conversation.
- if (editGroupCuratedSchemaViewModel.problems.isNotEmpty()) {
- item {
- Column(
- modifier = Modifier.fillMaxWidth(),
- verticalArrangement = Arrangement.spacedBy(
- MaterialTheme.spacing.relatedGap
- )
- ) {
- editGroupCuratedSchemaViewModel.problems.forEach { problem ->
- Text(
- text = problem.message(),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.error
- )
- }
- }
- }
- }
-
- item {
- SchemaTextField(
- state = identifierFieldState,
- icon = Icons.Default.Tag,
- iconDescription = "Identifier of the list",
- label = stringResource(Res.string.identifier),
- placeholder = stringResource(Res.string.eg_bitcoin_mov),
- // Locked on an edit: see the screen's own note.
- readOnly = existing != null,
- supportingText = if (existing != null) {
- stringResource(Res.string.the_identifier_names_this_list_in_every_reply)
- } else {
- null
- },
- isError = editGroupCuratedSchemaViewModel.problems.containsAny(
- CuratedSchemaProblem.IdentifierMissing,
- CuratedSchemaProblem.IdentifierTooLong
- )
- )
- }
-
- item {
- SchemaTextField(
- state = titleFieldState,
- icon = Icons.Default.Title,
- iconDescription = "Title of the schema",
- label = stringResource(Res.string.title),
- placeholder = stringResource(Res.string.eg_bitcoin_mov_suggestion),
- isError = editGroupCuratedSchemaViewModel.problems.containsAny(
- CuratedSchemaProblem.TitleMissing,
- CuratedSchemaProblem.TitleTooLong
- )
- )
- }
-
- item {
- SchemaTextField(
- state = nameFieldState,
- icon = Icons.AutoMirrored.Filled.Label,
- iconDescription = "Name of the list",
- label = stringResource(Res.string.name),
- placeholder = stringResource(Res.string.eg_bitcoin_mov),
- isError = editGroupCuratedSchemaViewModel.problems.containsAny(
- CuratedSchemaProblem.NameMissing,
- CuratedSchemaProblem.NameTooLong
- )
- )
- }
-
- item {
- SchemaTextField(
- state = descriptionFieldState,
- icon = Icons.AutoMirrored.Filled.Notes,
- iconDescription = "What the list is for",
- label = stringResource(Res.string.description),
- placeholder = stringResource(
- Res.string.eg_fields_for_a_bitcoin_mov_entry
- ),
- // The one identity field with prose in it, and the
- // only one a single line would truncate while it
- // was being typed.
- lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 4),
- isError = editGroupCuratedSchemaViewModel.problems.containsAny(
- CuratedSchemaProblem.DescriptionMissing,
- CuratedSchemaProblem.DescriptionTooLong
- )
- )
- }
-
- item {
- Column(
- modifier = Modifier.fillMaxWidth(),
- verticalArrangement = Arrangement.spacedBy(
- MaterialTheme.spacing.relatedGap
- )
- ) {
- SectionHeading(stringResource(Res.string.visibility))
-
- SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
- CuratedVisibility.entries.forEachIndexed { index, option ->
- SegmentedButton(
- selected = option == editGroupCuratedSchemaViewModel.visibility,
- onClick = {
- editGroupCuratedSchemaViewModel.selectVisibility(option)
- },
- shape = SegmentedButtonDefaults.itemShape(
- index = index,
- count = CuratedVisibility.entries.size
- ),
- label = { Text(stringResource(option.label())) }
- )
- }
- }
-
- // What the chosen word actually decides. Three
- // words is three questions nobody can be expected
- // to hold apart from the words alone.
- Text(
- text = stringResource(
- editGroupCuratedSchemaViewModel.visibility.purpose()
- ),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- }
-
- item {
- SchemaTextField(
- state = pictureFieldState,
- icon = Icons.Default.Image,
- iconDescription = "Picture of the list",
- label = stringResource(Res.string.picture_url),
- placeholder = stringResource(Res.string.eg_https_example_com_group_png),
- isError = editGroupCuratedSchemaViewModel.problems.containsAny(CuratedSchemaProblem.PictureNotHttps)
- )
- }
-
- item {
- SchemaTextField(
- state = domainFieldState,
- icon = Icons.Default.Language,
- iconDescription = "Domain of the list",
- label = stringResource(Res.string.domain),
- placeholder = stringResource(Res.string.eg_example_com),
- supportingText = stringResource(
- Res.string.a_domain_replaces_the_name_wherever_the_list_is_shown
- ),
- isError = editGroupCuratedSchemaViewModel.problems.containsAny(CuratedSchemaProblem.DomainInvalid)
- )
- }
-
- item {
- SectionHeading(stringResource(Res.string.fields))
- }
-
- itemsIndexed(editGroupCuratedSchemaViewModel.fields) { index, field ->
- SchemaFieldRow(
- field = field,
- isMandatory = editGroupCuratedSchemaViewModel.isMandatory(field),
- onOpen = { editGroupCuratedSchemaViewModel.startEditingField(index) }
- )
- }
-
- item {
- TextButton(onClick = { editGroupCuratedSchemaViewModel.startAddingField() }) {
- Icon(Icons.Default.Add, contentDescription = Decorative)
-
- Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125))
-
- Text(stringResource(Res.string.add_field))
- }
- }
-
- item {
- Column(
- modifier = Modifier.fillMaxWidth(),
- verticalArrangement = Arrangement.spacedBy(
- MaterialTheme.spacing.relatedGap
- )
- ) {
- SectionHeading(stringResource(Res.string.at_least_one_of))
-
- Text(
- text = stringResource(
- Res.string.pick_two_or_more_fields_at_least_one_of_which_an_entry_must_have
- ),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- }
-
- itemsIndexed(editGroupCuratedSchemaViewModel.requireAny) { index, group ->
- RequireAnyRow(
- fieldNames = editGroupCuratedSchemaViewModel.fields.map { it.name },
- selected = group,
- onToggle = { fieldName ->
- editGroupCuratedSchemaViewModel.toggleRequireAny(index, fieldName)
- },
- onRemove = { editGroupCuratedSchemaViewModel.removeRequireAnyGroup(index) }
- )
- }
-
- item {
- TextButton(
- onClick = { editGroupCuratedSchemaViewModel.addRequireAnyGroup() }
- ) {
- Icon(Icons.AutoMirrored.Filled.Rule, contentDescription = Decorative)
-
- Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125))
-
- Text(stringResource(Res.string.add_rule))
- }
- }
-
- // Only where the visibility gives them something to be: on
- // a public list anyone may suggest, and a list of people
- // who may would say the opposite of what the word does.
- if (editGroupCuratedSchemaViewModel.visibility != CuratedVisibility.Public) {
- item {
- Column(
- modifier = Modifier.fillMaxWidth(),
- verticalArrangement = Arrangement.spacedBy(
- MaterialTheme.spacing.relatedGap
- )
- ) {
- SectionHeading(stringResource(Res.string.suggesters))
-
- Text(
- text = stringResource(
- Res.string.pubkeys_besides_the_group_that_may_suggest_to_this_list
- ),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- }
-
- items(editGroupCuratedSchemaViewModel.suggesters) { pubkey ->
- RemovableRow(
- // The npub, because that is the form a member
- // pasted it in and the form they would compare
- // it against; whole and monospaced, so it can be.
- text = pubkey.hexToNpubHrp(),
- isMonospaced = true,
- removeDescription = "Remove suggester",
- onRemove = { editGroupCuratedSchemaViewModel.removeSuggester(pubkey) }
- )
- }
-
- item {
- AddRow(
- state = suggesterFieldState,
- icon = Icons.Default.PersonAdd,
- label = stringResource(Res.string.npub_or_hex_pubkey),
- placeholder = null,
- addDescription = "Add suggester",
- failure = when (editGroupCuratedSchemaViewModel.addSuggesterFailure) {
- AddSuggesterFailure.NotAPubkey ->
- stringResource(Res.string.that_is_not_a_pubkey)
- AddSuggesterFailure.AlreadyListed ->
- stringResource(Res.string.that_pubkey_is_already_a_suggester)
- null -> null
- },
- onAdd = { input -> editGroupCuratedSchemaViewModel.addSuggester(input) }
- )
- }
- }
-
- item {
- Column(
- modifier = Modifier.fillMaxWidth(),
- verticalArrangement = Arrangement.spacedBy(
- MaterialTheme.spacing.relatedGap
- )
- ) {
- SectionHeading(stringResource(Res.string.relays))
-
- Text(
- text = stringResource(
- Res.string.where_suggestions_to_this_list_are_published
- ),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
-
- if (editGroupCuratedSchemaViewModel.relays.isEmpty()) {
- Text(
- text = stringResource(
- Res.string.no_relays_named_so_a_client_will_pick_its_own
- ),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.error
- )
- }
- }
- }
-
- items(editGroupCuratedSchemaViewModel.relays) { url ->
- RemovableRow(
- text = url,
- isMonospaced = false,
- removeDescription = "Remove relay",
- onRemove = { editGroupCuratedSchemaViewModel.removeRelay(url) }
- )
- }
-
- item {
- AddRow(
- state = relayFieldState,
- icon = Icons.Default.Dns,
- label = stringResource(Res.string.relay_url),
- placeholder = stringResource(Res.string.eg_wss_relay_example_com),
- addDescription = "Add relay",
- failure = when (editGroupCuratedSchemaViewModel.addRelayFailure) {
- AddRelayFailure.NotARelay ->
- stringResource(Res.string.that_is_not_a_relay_address)
- AddRelayFailure.AlreadyListed ->
- stringResource(Res.string.that_relay_is_already_in_this_list)
- null -> null
- },
- onAdd = { input -> editGroupCuratedSchemaViewModel.addRelay(input) }
- )
- }
- }
- }
-
- editGroupCuratedSchemaViewModel.editingField?.let { edit ->
- FieldEditorSheet(
- edit = edit,
- isMandatory = editGroupCuratedSchemaViewModel.isMandatory(edit.field),
- onCommit = { field -> editGroupCuratedSchemaViewModel.commitField(field) },
- onRemove = { edit.index?.let(editGroupCuratedSchemaViewModel::removeField) },
- onDismiss = { editGroupCuratedSchemaViewModel.dismissFieldEditor() }
- )
- }
- }
-
- EditGroupCuratedSchemaUIState.Loading -> {
- Column(
- modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.screenMargin),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap)
- ) {
- Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap))
-
- Text(
- text = stringResource(
- if (identifier == null) Res.string.add_schema else Res.string.edit_schema
- ),
- style = MaterialTheme.typography.bodyLarge,
- textAlign = TextAlign.Center
- )
-
- LoadingDataIndicator(fillScreen = false)
- }
- }
- }
- }
-
- LaunchedEffect(true) {
- if (initialEditGroupCuratedSchemaUIState == EditGroupCuratedSchemaUIState.Loading) {
- editGroupCuratedSchemaViewModel.initiateEditGroupCuratedSchema()
- }
- }
-}
-
-/** The word for who may suggest, for the group's screen and the editor alike. */
-internal fun CuratedVisibility.label(): StringResource = when (this) {
- CuratedVisibility.Public -> Res.string.visibility_public
- CuratedVisibility.Closed -> Res.string.visibility_closed
- CuratedVisibility.Private -> Res.string.visibility_private
-}
-
-/** The sentence saying what the word decides. */
-private fun CuratedVisibility.purpose(): StringResource = when (this) {
- CuratedVisibility.Public -> Res.string.visibility_public_purpose
- CuratedVisibility.Closed -> Res.string.visibility_closed_purpose
- CuratedVisibility.Private -> Res.string.visibility_private_purpose
-}
-
-/**
- * The name of a field type, for the chips and the rows.
- *
- * A `when` rather than a field on each entry so that the enum stays what it is
- * -- the NIP's nine words and their one rule each -- with no catalogue behind
- * it, and so that a tenth type is a compile error here rather than a chip with
- * no name.
- */
-private fun CuratedFieldType.label(): StringResource = when (this) {
- CuratedFieldType.Text -> Res.string.field_type_text
- CuratedFieldType.LongText -> Res.string.field_type_longtext
- CuratedFieldType.Token -> Res.string.field_type_token
- CuratedFieldType.Url -> Res.string.field_type_url
- CuratedFieldType.Image -> Res.string.field_type_image
- CuratedFieldType.Enum -> Res.string.field_type_enum
- CuratedFieldType.Year -> Res.string.field_type_year
- CuratedFieldType.Duration -> Res.string.field_type_duration
- CuratedFieldType.Number -> Res.string.field_type_number
-}
-
-/**
- * Why a schema cannot be proposed, said to the member.
- *
- * The caps are quoted from `CuratedSchemaEvent` rather than repeated, so the
- * sentence and the check cannot disagree about the number.
- */
-@Composable
-internal fun CuratedSchemaProblem.message(): String = when (this) {
- CuratedSchemaProblem.IdentifierMissing -> stringResource(Res.string.an_identifier_is_required)
- CuratedSchemaProblem.IdentifierTooLong -> stringResource(
- Res.string.the_identifier_must_be_at_most_n_characters,
- CuratedSchemaEvent.MAX_IDENTIFIER.toString()
- )
- CuratedSchemaProblem.TitleMissing -> stringResource(Res.string.a_title_is_required)
- CuratedSchemaProblem.TitleTooLong -> stringResource(
- Res.string.the_title_must_be_at_most_n_characters,
- CuratedSchemaEvent.MAX_TITLE.toString()
- )
- CuratedSchemaProblem.NameMissing -> stringResource(Res.string.a_name_is_required)
- CuratedSchemaProblem.NameTooLong -> stringResource(
- Res.string.the_name_must_be_at_most_n_characters,
- CuratedSchemaEvent.MAX_NAME.toString()
- )
- CuratedSchemaProblem.DescriptionMissing -> stringResource(Res.string.a_description_is_required)
- CuratedSchemaProblem.DescriptionTooLong -> stringResource(
- Res.string.the_description_must_be_at_most_n_characters,
- CuratedSchemaEvent.MAX_DESCRIPTION.toString()
- )
- CuratedSchemaProblem.VisibilityMissing -> stringResource(Res.string.say_who_may_suggest_to_this_list)
- CuratedSchemaProblem.PictureNotHttps -> stringResource(Res.string.the_picture_must_be_an_https_url)
- CuratedSchemaProblem.DomainInvalid -> stringResource(Res.string.that_is_not_a_domain_name)
- CuratedSchemaProblem.NoFields -> stringResource(Res.string.a_schema_needs_at_least_one_field)
- CuratedSchemaProblem.RelayInvalid -> stringResource(Res.string.one_of_the_relays_is_not_a_relay_address)
-}
-
-/** Whether any of [problems] is about the field asking, for its error state. */
-private fun List.containsAny(vararg problems: CuratedSchemaProblem): Boolean =
- problems.any { it in this }
-
-/** A heading over one part of the form, at the leading edge like the rows under it. */
-@Composable
-private fun SectionHeading(text: String) {
- Text(
- modifier = Modifier.fillMaxWidth(),
- text = text,
- style = MaterialTheme.typography.titleSmall
- )
-}
-
-/**
- * One text field of the form, in the shape the app's other forms use.
- *
- * The borderless outlined field over the bottom bar's tint is four lines of
- * colours apiece, and this screen has more of them than any other -- so the
- * function, and the three things this one adds over `NostrProfileField`: a
- * read-only state for the locked identifier, an error state for a field a
- * problem is about, and a line of supporting text for a field that needs one.
- */
-@Composable
-private fun SchemaTextField(
- state: TextFieldState,
- icon: ImageVector,
- iconDescription: String,
- label: String,
- placeholder: String,
- lineLimits: TextFieldLineLimits = TextFieldLineLimits.SingleLine,
- readOnly: Boolean = false,
- isError: Boolean = false,
- supportingText: String? = null
-) {
- OutlinedTextField(
- modifier = Modifier.fillMaxWidth().background(BottomAppBarDefaults.containerColor),
- state = state,
- lineLimits = lineLimits,
- readOnly = readOnly,
- isError = isError,
- colors = OutlinedTextFieldDefaults.colors(
- focusedBorderColor = Color.Transparent,
- unfocusedBorderColor = Color.Transparent,
- disabledBorderColor = Color.Transparent
- ),
- leadingIcon = {
- Icon(icon, contentDescription = iconDescription)
- },
- label = {
- Text(text = label)
- },
- placeholder = {
- Text(text = placeholder)
- },
- supportingText = supportingText?.let { { Text(text = it) } }
- )
-}
-
-/**
- * One field of the schema, as a row that opens the sheet.
- *
- * The label is what a suggester will see over the input, so it is the headline;
- * the name, type and requirement are what the schema says about it. A
- * mandatory field is marked as required whatever its own flag says, since that
- * is what will be signed.
- */
-@Composable
-private fun SchemaFieldRow(
- field: CuratedField,
- isMandatory: Boolean,
- onOpen: () -> Unit
-) {
- Card(modifier = Modifier.fillMaxWidth(), onClick = onOpen) {
- ListItem(
- leadingContent = {
- Icon(Icons.Default.Ballot, contentDescription = Decorative)
- },
- headlineContent = {
- Text(
- text = field.labelOrName(),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- },
- supportingContent = {
- Text(
- text = listOfNotNull(
- field.name,
- stringResource(field.type.label()),
- stringResource(
- if (field.isRequired || isMandatory) Res.string.required else Res.string.optional
- ),
- stringResource(Res.string.derived).takeIf { field.config.derived == true }
- ).joinToString(" · "),
- maxLines = 2,
- overflow = TextOverflow.Ellipsis
- )
- }
- )
- }
-}
-
-/**
- * One `require-any` rule: a chip per field, selected for the ones in the rule.
- *
- * Chips over a picker because the whole point of a rule is which fields are in
- * it, and a chip row shows that at a glance where a picker would show a count.
- */
-@OptIn(ExperimentalLayoutApi::class)
-@Composable
-private fun RequireAnyRow(
- fieldNames: List,
- selected: List,
- onToggle: (String) -> Unit,
- onRemove: () -> Unit
-) {
- Card(modifier = Modifier.fillMaxWidth()) {
- Row(
- modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.compactPadding),
- verticalAlignment = Alignment.CenterVertically
- ) {
- FlowRow(
- modifier = Modifier.weight(1f),
- horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)
- ) {
- fieldNames.forEach { fieldName ->
- FilterChip(
- selected = fieldName in selected,
- onClick = { onToggle(fieldName) },
- label = { Text(fieldName) }
- )
- }
- }
-
- IconButton(onClick = onRemove) {
- Icon(
- Icons.Default.Delete,
- contentDescription = "Remove rule",
- tint = MaterialTheme.colorScheme.error
- )
- }
- }
- }
-}
-
-/** One entry of a plain list -- a relay, a suggester -- with the way to drop it. */
-@Composable
-private fun RemovableRow(
- text: String,
- isMonospaced: Boolean,
- removeDescription: String,
- onRemove: () -> Unit
-) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text(
- modifier = Modifier.weight(1f),
- text = text,
- style = MaterialTheme.typography.bodyMedium,
- fontFamily = if (isMonospaced) FontFamily.Monospace else null
- )
-
- IconButton(onClick = onRemove) {
- Icon(
- Icons.Default.Delete,
- contentDescription = removeDescription,
- tint = MaterialTheme.colorScheme.error
- )
- }
- }
-}
-
-/**
- * A field and a button that adds what is in it to a list, and the reason when
- * it would not.
- *
- * [onAdd] answers whether the row appeared, which is the cue to clear the field
- * -- a refused value stays where it can be corrected.
- */
-@Composable
-private fun AddRow(
- state: TextFieldState,
- icon: ImageVector,
- label: String,
- placeholder: String?,
- addDescription: String,
- failure: String?,
- onAdd: (String) -> Boolean
-) {
- Column(
- modifier = Modifier.fillMaxWidth(),
- verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)
- ) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically
- ) {
- OutlinedTextField(
- modifier = Modifier.weight(1f).background(BottomAppBarDefaults.containerColor),
- state = state,
- lineLimits = TextFieldLineLimits.SingleLine,
- colors = OutlinedTextFieldDefaults.colors(
- focusedBorderColor = Color.Transparent,
- unfocusedBorderColor = Color.Transparent,
- disabledBorderColor = Color.Transparent
- ),
- leadingIcon = {
- Icon(icon, contentDescription = Decorative)
- },
- label = { Text(label) },
- placeholder = placeholder?.let { { Text(it) } },
- isError = failure != null
- )
-
- Spacer(modifier = Modifier.width(MaterialTheme.spacing.itemGap))
-
- IconButton(
- onClick = {
- if (onAdd(state.text.toString())) {
- state.clearText()
- }
- }
- ) {
- Icon(Icons.Default.Add, contentDescription = addDescription)
- }
- }
-
- failure?.let {
- Text(
- text = it,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.error
- )
- }
- }
-}
-
-/**
- * The sheet a field is edited on.
- *
- * Split into the sheet and its content the way `GroupKeyStateSheet` is, so the
- * content can be rendered and measured on its own -- a bottom sheet is a popup
- * with its own window, which a layout test cannot reach into.
- */
-@Composable
-private fun FieldEditorSheet(
- edit: FieldEdit,
- isMandatory: Boolean,
- onCommit: (CuratedField) -> FieldProblem?,
- onRemove: () -> Unit,
- onDismiss: () -> Unit
-) {
- ModalBottomSheet(onDismiss = onDismiss, skipPartiallyExpanded = true) {
- FieldEditorSheetContent(
- edit = edit,
- isMandatory = isMandatory,
- onCommit = onCommit,
- onRemove = onRemove
- )
- }
-}
-
-/**
- * Everything a field can say about itself, and a button that puts it in the
- * schema.
- *
- * The positional parts of the tag first -- name, label, placeholder, type,
- * required -- because they are what a form needs; the config keys after, and
- * only the ones the chosen type gives a meaning to. A minimum on a text field or
- * options on a year would be written into the event and mean nothing, so they
- * are not offered and, when the type changes away from them, not kept.
- *
- * **A mandatory field cannot stop being one.** Its tag is read-only and its
- * requirement is on and disabled, because `CuratedSchema.normalized` would only
- * put a field writing to `d` or `title` back if this let it be taken away -- so
- * offering the change would offer something the event will not carry.
- */
-@OptIn(ExperimentalLayoutApi::class)
-@Composable
-internal fun FieldEditorSheetContent(
- edit: FieldEdit,
- isMandatory: Boolean,
- onCommit: (CuratedField) -> FieldProblem?,
- onRemove: () -> Unit
-) {
- val field = edit.field
-
- val nameFieldState = rememberTextFieldState(field.name)
- val labelFieldState = rememberTextFieldState(field.label)
- val placeholderFieldState = rememberTextFieldState(field.placeholder)
- val tagFieldState = rememberTextFieldState(field.config.tag ?: "")
- val markerFieldState = rememberTextFieldState(field.config.marker ?: "")
- val maxFieldState = rememberTextFieldState(field.config.max?.toString() ?: "")
- val minFieldState = rememberTextFieldState(field.config.min?.toString() ?: "")
- val optionsFieldState = rememberTextFieldState(field.config.options?.joinToString("\n") ?: "")
- val patternFieldState = rememberTextFieldState(field.config.pattern ?: "")
- val hintFieldState = rememberTextFieldState(field.config.hint ?: "")
-
- var type by rememberSaveable { mutableStateOf(field.type) }
- var isRequired by rememberSaveable { mutableStateOf(field.isRequired || isMandatory) }
- var mayRepeat by rememberSaveable { mutableStateOf(field.config.repeat == true) }
- var isHttpsOnly by rememberSaveable { mutableStateOf(field.config.https == true) }
- var isDerived by rememberSaveable { mutableStateOf(field.config.derived == true) }
-
- var problem: FieldProblem? by remember { mutableStateOf(null) }
-
- Column(
- modifier = Modifier.fillMaxWidth(),
- verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap)
- ) {
- Text(
- text = stringResource(
- if (edit.index == null) Res.string.new_field else Res.string.edit_field
- ),
- style = MaterialTheme.typography.titleMedium
- )
-
- SchemaTextField(
- state = nameFieldState,
- icon = Icons.Default.Tag,
- iconDescription = "Name of the field",
- label = stringResource(Res.string.field_name),
- placeholder = stringResource(Res.string.eg_watchurl),
- isError = problem != null
- )
-
- // Named rather than silent, under the field it is about.
- problem?.let {
- Text(
- text = when (it) {
- FieldProblem.NameMissing -> stringResource(Res.string.a_field_needs_a_name)
- FieldProblem.NameHasSpaces -> stringResource(Res.string.a_field_name_is_one_word)
- FieldProblem.NameTaken -> stringResource(Res.string.a_field_named_that_already_exists)
- },
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.error
- )
- }
-
- SchemaTextField(
- state = labelFieldState,
- icon = Icons.AutoMirrored.Filled.Label,
- iconDescription = "Label over the input",
- label = stringResource(Res.string.label),
- placeholder = stringResource(Res.string.eg_watch_reference_url)
- )
-
- SchemaTextField(
- state = placeholderFieldState,
- icon = Icons.AutoMirrored.Filled.ShortText,
- iconDescription = "Example shown in an empty input",
- label = stringResource(Res.string.placeholder),
- placeholder = stringResource(Res.string.eg_https_youtube_com_watch)
- )
-
- Text(
- text = stringResource(Res.string.type),
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
-
- FlowRow(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)
- ) {
- CuratedFieldType.entries.forEach { option ->
- FilterChip(
- selected = option == type,
- onClick = { type = option },
- label = { Text(stringResource(option.label())) }
- )
- }
- }
-
- SwitchRow(
- label = stringResource(Res.string.an_entry_must_have_this_field),
- checked = isRequired,
- enabled = !isMandatory,
- onCheckedChange = { isRequired = it }
- )
-
- SchemaTextField(
- state = tagFieldState,
- icon = Icons.Default.Sell,
- iconDescription = "Tag the field writes to",
- label = stringResource(Res.string.writes_to_tag),
- placeholder = nameFieldState.text.toString().ifBlank { stringResource(Res.string.eg_watchurl) },
- readOnly = isMandatory,
- supportingText = stringResource(
- Res.string.defaults_to_the_field_name_content_means_the_body
- )
- )
-
- SchemaTextField(
- state = markerFieldState,
- icon = Icons.Default.Bookmark,
- iconDescription = "Marker on the tag",
- label = stringResource(Res.string.marker),
- placeholder = stringResource(Res.string.eg_watch),
- supportingText = stringResource(
- Res.string.the_tag_s_third_element_which_tells_fields_sharing_a_tag_apart
- )
- )
-
- SchemaTextField(
- state = maxFieldState,
- icon = Icons.Default.Numbers,
- iconDescription = "Maximum",
- label = stringResource(
- if (type.isNumeric) Res.string.maximum_value else Res.string.maximum_characters
- ),
- placeholder = stringResource(if (type.isNumeric) Res.string.eg_2100 else Res.string.eg_200)
- )
-
- if (type.isNumeric) {
- SchemaTextField(
- state = minFieldState,
- icon = Icons.Default.Numbers,
- iconDescription = "Minimum",
- label = stringResource(Res.string.minimum_value),
- placeholder = stringResource(Res.string.eg_1900)
- )
- }
-
- if (type == CuratedFieldType.Enum) {
- SchemaTextField(
- state = optionsFieldState,
- icon = Icons.Default.Checklist,
- iconDescription = "Allowed values",
- label = stringResource(Res.string.options),
- placeholder = stringResource(Res.string.eg_movie_documentary_short),
- lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 6),
- supportingText = stringResource(Res.string.one_per_line_or_separated_by_commas)
- )
- }
-
- if (type == CuratedFieldType.Url) {
- SwitchRow(
- label = stringResource(Res.string.https_only),
- checked = isHttpsOnly,
- enabled = true,
- onCheckedChange = { isHttpsOnly = it }
- )
- }
-
- SwitchRow(
- label = stringResource(Res.string.may_appear_more_than_once),
- checked = mayRepeat,
- enabled = true,
- onCheckedChange = { mayRepeat = it }
- )
-
- SchemaTextField(
- state = patternFieldState,
- icon = Icons.AutoMirrored.Filled.Rule,
- iconDescription = "Pattern the value must match",
- label = stringResource(Res.string.pattern),
- placeholder = stringResource(Res.string.eg_a_za_z_2_8),
- supportingText = stringResource(
- Res.string.a_regular_expression_the_whole_value_must_match
- )
- )
-
- SwitchRow(
- label = stringResource(Res.string.filled_in_by_the_app_never_asked_for),
- checked = isDerived,
- enabled = true,
- onCheckedChange = { isDerived = it }
- )
-
- SchemaTextField(
- state = hintFieldState,
- icon = Icons.Default.Lightbulb,
- iconDescription = "Helper text",
- label = stringResource(Res.string.hint),
- placeholder = stringResource(Res.string.helper_text_under_the_input)
- )
-
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically
- ) {
- if (!isMandatory && edit.index != null) {
- TextButton(
- onClick = onRemove,
- colors = ButtonDefaults.textButtonColors(
- contentColor = MaterialTheme.colorScheme.error
- )
- ) {
- Text(stringResource(Res.string.remove_field))
- }
- }
-
- Spacer(modifier = Modifier.weight(1f))
-
- Button(
- onClick = {
- problem = onCommit(
- CuratedField(
- name = nameFieldState.text.toString().trim(),
- type = type,
- isRequired = isRequired || isMandatory,
- placeholder = placeholderFieldState.text.toString().trim(),
- label = labelFieldState.text.toString().trim(),
- config = field.config.copy(
- // Pinned for a mandatory field, so that renaming it
- // cannot move it off the tag that makes it one.
- tag = if (isMandatory) {
- field.tag()
- } else {
- tagFieldState.text.toString().trim().ifEmpty { null }
- },
- marker = markerFieldState.text.toString().trim().ifEmpty { null },
- max = maxFieldState.text.toString().trim().toLongOrNull(),
- min = if (type.isNumeric) {
- minFieldState.text.toString().trim().toLongOrNull()
- } else {
- null
- },
- options = if (type == CuratedFieldType.Enum) {
- optionsFieldState.text.toString()
- .split('\n', ',')
- .map { it.trim() }
- .filter { it.isNotEmpty() }
- .ifEmpty { null }
- } else {
- null
- },
- https = if (type == CuratedFieldType.Url && isHttpsOnly) true else null,
- repeat = if (mayRepeat) true else null,
- pattern = patternFieldState.text.toString().trim().ifEmpty { null },
- derived = if (isDerived) true else null,
- hint = hintFieldState.text.toString().trim().ifEmpty { null },
- )
- )
- )
- }
- ) {
- Text(stringResource(Res.string.done))
- }
- }
- }
-}
-
-/**
- * A labelled switch, the label taking the width and the switch its own target.
- *
- * The label is also the switch's description, so a screen reader announces what
- * is being turned on rather than "switch, off" beside a sentence it has already
- * read past.
- */
-@Composable
-private fun SwitchRow(
- label: String,
- checked: Boolean,
- enabled: Boolean,
- onCheckedChange: (Boolean) -> Unit
-) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text(
- modifier = Modifier.weight(1f),
- text = label,
- style = MaterialTheme.typography.bodyMedium
- )
-
- Switch(
- modifier = Modifier.semantics { contentDescription = label },
- checked = checked,
- enabled = enabled,
- onCheckedChange = onCheckedChange
- )
- }
-}
-
-@ConformancePreviews
-@Composable
-private fun EditGroupCuratedSchemaScreenPreview() {
- MantraTheme {
- Surface(modifier = Modifier.fillMaxSize()) {
- EditGroupCuratedSchemaScreen(
- activeUserPublicKey = "",
- chatRoomId = "publicKey",
- relayHint = null,
- identifier = null,
- initialEditGroupCuratedSchemaUIState = EditGroupCuratedSchemaUIState.Loaded(
- localChatRoom = LocalChatRoom(
- chatRoom = ChatRoom(
- id = "publicKey",
- userPublicKey = "",
- subject = "Translation room",
- description = "A group translating hard books.",
- initialGiftWrapPayloadId = "sdfaer",
- mlsGroupState = "state"
- ),
- ),
- // A new schema, so the editor seeds the two fields every list
- // has and the preview shows what a group actually starts from
- // rather than an empty form.
- defaultRelays = listOf("wss://relay.example.com"),
- // A group that can sign, so the form renders in the state a
- // member actually meets it in rather than greyed out.
- canSign = true
- ),
- chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
- frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
- onNavigateToRouteAndPopUpInclusive = {},
- onNavigateBack = {},
- )
- }
- }
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupNostrProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupNostrProfileScreen.kt
deleted file mode 100644
index 6914b722..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupNostrProfileScreen.kt
+++ /dev/null
@@ -1,427 +0,0 @@
-package press.mantra.compose.ui.composable
-
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.imePadding
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.text.input.TextFieldLineLimits
-import androidx.compose.foundation.text.input.TextFieldState
-import androidx.compose.foundation.text.input.rememberTextFieldState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.Notes
-import androidx.compose.material.icons.filled.AlternateEmail
-import androidx.compose.material.icons.filled.Bolt
-import androidx.compose.material.icons.filled.Draw
-import androidx.compose.material.icons.filled.Image
-import androidx.compose.material.icons.filled.Link
-import androidx.compose.material.icons.filled.Title
-import androidx.compose.material3.BottomAppBar
-import androidx.compose.material3.BottomAppBarDefaults
-import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
-import androidx.compose.material3.ExtendedFloatingActionButton
-import androidx.compose.material3.FloatingActionButtonDefaults
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.OutlinedTextFieldDefaults
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.SnackbarHost
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBar
-import androidx.compose.material3.contentColorFor
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.semantics.disabled
-import androidx.compose.ui.semantics.semantics
-import androidx.compose.ui.text.style.TextAlign
-import androidx.lifecycle.viewmodel.compose.viewModel
-import press.mantra.compose.database.model.ChatRoom
-import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.repository.ChatRepository
-import press.mantra.compose.repository.FrostSigningRepository
-import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
-import press.mantra.compose.ui.composable.navigation.routes.Route
-import press.mantra.compose.ui.composable.widgets.ErrorState
-import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
-import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
-import press.mantra.compose.ui.composable.widgets.NavigateBackButton
-import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
-import press.mantra.compose.ui.composable.widgets.rememberNotifier
-import press.mantra.compose.ui.theme.ConformancePreviews
-import press.mantra.compose.ui.theme.MantraTheme
-import press.mantra.compose.ui.theme.readableContent
-import press.mantra.compose.ui.theme.spacing
-import press.mantra.compose.ui.view.model.EditGroupNostrProfileViewModel
-import press.mantra.compose.ui.view.state.EditGroupNostrProfileUIState
-import com.vitorpamplona.quartz.nip01Core.core.HexKey
-import mantra.composeapp.generated.resources.Res
-import mantra.composeapp.generated.resources.about
-import mantra.composeapp.generated.resources.could_not_ask_the_group_to_sign_this_profile
-import mantra.composeapp.generated.resources.edit_nostr_profile
-import mantra.composeapp.generated.resources.eg_a_group_translating_hard_books
-import mantra.composeapp.generated.resources.eg_group_example_com
-import mantra.composeapp.generated.resources.eg_group_getalby_com
-import mantra.composeapp.generated.resources.eg_https_example_com
-import mantra.composeapp.generated.resources.eg_https_example_com_group_png
-import mantra.composeapp.generated.resources.eg_translation_collective
-import mantra.composeapp.generated.resources.lightning_address
-import mantra.composeapp.generated.resources.name
-import mantra.composeapp.generated.resources.nostr_address
-import mantra.composeapp.generated.resources.picture_url
-import mantra.composeapp.generated.resources.propose_nostr_profile
-import mantra.composeapp.generated.resources.the_group_signs_its_profile_so_it_takes_a_quorum
-import mantra.composeapp.generated.resources.this_group_has_no_shared_key_to_sign_a_profile
-import mantra.composeapp.generated.resources.website
-import mantra.composeapp.generated.resources.what_the_group_says_about_itself_on_nostr
-import org.jetbrains.compose.resources.stringResource
-
-/**
- * The form for what a group says about itself on nostr.
- *
- * A group's kind:0 is signed by the group, so pressing the button proposes rather
- * than saves: the form goes out as one event for the room's quorum to put its key
- * to, and the screen hands over to the signing session it opened. Nothing about the
- * profile has changed by the time this screen closes.
- *
- * The fields start at what the group already says, so an edit is an edit. And they
- * are sent as they stand -- **an emptied field clears what the group had said**,
- * which is the only way to unsay something a group has signed. Fields this form
- * does not offer are carried across untouched; see `GroupNostrProfile.template`.
- */
-@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
-@Composable
-fun EditGroupNostrProfileScreen(
- activeUserPublicKey: HexKey,
- chatRoomId: String,
- relayHint: String?,
- initialEditGroupNostrProfileUIState: EditGroupNostrProfileUIState =
- EditGroupNostrProfileUIState.Loading,
- chatRepository: ChatRepository,
- frostSigningRepository: FrostSigningRepository,
- onNavigateToRouteAndPopUpInclusive: (Route) -> Unit,
- onNavigateBack: () -> Unit,
-) {
- val editGroupNostrProfileViewModel: EditGroupNostrProfileViewModel = viewModel(
- factory = EditGroupNostrProfileViewModel.factory(
- chatRoomId = chatRoomId,
- relayHint = relayHint,
- initialEditGroupNostrProfileUIState = initialEditGroupNostrProfileUIState,
- activeUserPublicKey = activeUserPublicKey,
- chatRepository = chatRepository,
- frostSigningRepository = frostSigningRepository
- )
- )
-
- // Read out here rather than in the click handler: both are composable and an
- // onClick lambda is not. The scope is the caller's so the message survives this
- // screen being replaced by the signing session.
- val notify = rememberNotifier(rememberCoroutineScope())
- val couldNotPropose = stringResource(Res.string.could_not_ask_the_group_to_sign_this_profile)
-
- ScreenStateTransition(editGroupNostrProfileViewModel.editGroupNostrProfileUIState) { uiState ->
- when (val editGroupNostrProfileUIState = uiState) {
- is EditGroupNostrProfileUIState.Error -> {
- // Nothing to retry: the room came from a navigation argument, and
- // reading it again with the same one fails the same way.
- ErrorState(
- message = editGroupNostrProfileUIState.message,
- onRetry = null
- )
- }
-
- is EditGroupNostrProfileUIState.Loaded -> {
- val profile = editGroupNostrProfileUIState.groupNostrProfile
-
- // Seeded from the profile the group already signed, so the form is
- // an edit of it. `rememberTextFieldState` saves what is typed, which
- // is what survives a rotation with the edits intact.
- val nameFieldState = rememberTextFieldState(profile?.name() ?: "")
- val aboutFieldState = rememberTextFieldState(profile?.about() ?: "")
- val pictureFieldState = rememberTextFieldState(profile?.picture() ?: "")
- val websiteFieldState = rememberTextFieldState(profile?.website() ?: "")
- val nip05FieldState = rememberTextFieldState(profile?.nip05() ?: "")
- val lud16FieldState = rememberTextFieldState(profile?.lud16() ?: "")
-
- // M3 gives a FAB no `enabled`, so borrow the disabled colours every
- // other button in the app uses rather than inventing a shade here.
- val buttonColors = ButtonDefaults.buttonColors()
- val canSign = editGroupNostrProfileUIState.canSign
-
- // imePadding: this screen is nothing but text fields, and without it
- // the software keyboard covers whichever one is being typed into.
- Scaffold(
- snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
- modifier = Modifier.imePadding(),
- topBar = {
- TopAppBar(
- title = {
- editGroupNostrProfileUIState.localChatRoom
- .RenderChatRoomTitleText()
- },
- navigationIcon = { NavigateBackButton(onNavigateBack) },
- )
- },
- bottomBar = {
- BottomAppBar(
- actions = {},
- floatingActionButton = {
- ExtendedFloatingActionButton(
- modifier = if (canSign) {
- Modifier
- } else {
- // Looking unavailable is not being unavailable.
- Modifier.semantics { disabled() }
- },
- containerColor = if (canSign) {
- FloatingActionButtonDefaults.containerColor
- } else {
- buttonColors.disabledContainerColor
- },
- contentColor = if (canSign) {
- contentColorFor(FloatingActionButtonDefaults.containerColor)
- } else {
- buttonColors.disabledContentColor
- },
- onClick = {
- if (!canSign) return@ExtendedFloatingActionButton
-
- editGroupNostrProfileViewModel.proposeNostrProfile(
- localChatRoom =
- editGroupNostrProfileUIState.localChatRoom,
- groupNostrProfile = profile,
- nameField = nameFieldState,
- aboutField = aboutFieldState,
- pictureField = pictureFieldState,
- websiteField = websiteFieldState,
- nip05Field = nip05FieldState,
- lud16Field = lud16FieldState,
- onSuccess = { sessionId ->
- // Onto the session rather than back
- // to the group. The profile has not
- // changed yet -- it changes when a
- // quorum signs -- so landing on a
- // group still showing the old one
- // would read as a failure.
- onNavigateToRouteAndPopUpInclusive.invoke(
- FrostSigningRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- sessionId = sessionId
- )
- )
- },
- // Stay on the form with what was typed
- // still in it. The proposal is what
- // failed, and every word of it is worth
- // keeping for the retry.
- onFailure = { notify(couldNotPropose) }
- )
- }
- ) {
- Icon(
- Icons.Default.Draw,
- contentDescription = "Propose nostr profile"
- )
- Text(stringResource(Res.string.propose_nostr_profile))
- }
- }
- )
- }
- ) { innerPadding ->
- Column(
- modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
- .verticalScroll(rememberScrollState()),
- verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- Text(stringResource(Res.string.what_the_group_says_about_itself_on_nostr))
-
- Text(
- text = stringResource(
- Res.string.the_group_signs_its_profile_so_it_takes_a_quorum
- ),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- textAlign = TextAlign.Center
- )
-
- if (!canSign) {
- Text(
- text = stringResource(
- Res.string.this_group_has_no_shared_key_to_sign_a_profile
- ),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.error,
- textAlign = TextAlign.Center
- )
- }
-
- NostrProfileField(
- state = nameFieldState,
- icon = Icons.Default.Title,
- iconDescription = "Name of the group",
- label = stringResource(Res.string.name),
- placeholder = stringResource(Res.string.eg_translation_collective)
- )
-
- NostrProfileField(
- state = aboutFieldState,
- icon = Icons.AutoMirrored.Filled.Notes,
- iconDescription = "What the group is for",
- label = stringResource(Res.string.about),
- placeholder = stringResource(
- Res.string.eg_a_group_translating_hard_books
- ),
- // The one field with prose in it, and the only one a
- // single line would truncate while it was being typed.
- lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 3)
- )
-
- NostrProfileField(
- state = pictureFieldState,
- icon = Icons.Default.Image,
- iconDescription = "Picture of the group",
- label = stringResource(Res.string.picture_url),
- placeholder = stringResource(Res.string.eg_https_example_com_group_png)
- )
-
- NostrProfileField(
- state = websiteFieldState,
- icon = Icons.Default.Link,
- iconDescription = "Website of the group",
- label = stringResource(Res.string.website),
- placeholder = stringResource(Res.string.eg_https_example_com)
- )
-
- NostrProfileField(
- state = nip05FieldState,
- icon = Icons.Default.AlternateEmail,
- iconDescription = "Nostr address of the group",
- label = stringResource(Res.string.nostr_address),
- placeholder = stringResource(Res.string.eg_group_example_com)
- )
-
- NostrProfileField(
- state = lud16FieldState,
- icon = Icons.Default.Bolt,
- iconDescription = "Lightning address of the group",
- label = stringResource(Res.string.lightning_address),
- placeholder = stringResource(Res.string.eg_group_getalby_com)
- )
- }
- }
- }
-
- EditGroupNostrProfileUIState.Loading -> {
- Column(
- modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.screenMargin),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap)
- ) {
- Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap))
-
- Text(
- text = stringResource(Res.string.edit_nostr_profile),
- style = MaterialTheme.typography.bodyLarge,
- textAlign = TextAlign.Center
- )
-
- LoadingDataIndicator(fillScreen = false)
- }
- }
- }
- }
-
- LaunchedEffect(true) {
- if (initialEditGroupNostrProfileUIState == EditGroupNostrProfileUIState.Loading) {
- editGroupNostrProfileViewModel.initiateEditGroupNostrProfile()
- }
- }
-}
-
-/**
- * One field of the profile, in the shape the app's other forms use.
- *
- * Six of these on one screen is what makes it worth a function: the borderless
- * outlined field over the bottom bar's tint is four lines of colours apiece, and
- * six copies of that is where a divergence hides.
- */
-@Composable
-private fun NostrProfileField(
- state: TextFieldState,
- icon: ImageVector,
- iconDescription: String,
- label: String,
- placeholder: String,
- lineLimits: TextFieldLineLimits = TextFieldLineLimits.SingleLine
-) {
- OutlinedTextField(
- modifier = Modifier.fillMaxWidth().background(BottomAppBarDefaults.containerColor),
- state = state,
- lineLimits = lineLimits,
- colors = OutlinedTextFieldDefaults.colors(
- focusedBorderColor = Color.Transparent,
- unfocusedBorderColor = Color.Transparent,
- disabledBorderColor = Color.Transparent
- ),
- leadingIcon = {
- Icon(icon, contentDescription = iconDescription)
- },
- label = {
- Text(text = label)
- },
- placeholder = {
- Text(text = placeholder)
- }
- )
-}
-
-@ConformancePreviews
-@Composable
-private fun EditGroupNostrProfileScreenPreview() {
- MantraTheme {
- Surface(
- modifier = Modifier.fillMaxSize()
- ) {
- EditGroupNostrProfileScreen(
- activeUserPublicKey = "",
- chatRoomId = "publicKey",
- relayHint = null,
- initialEditGroupNostrProfileUIState = EditGroupNostrProfileUIState.Loaded(
- localChatRoom = LocalChatRoom(
- chatRoom = ChatRoom(
- id = "publicKey",
- userPublicKey = "",
- subject = "Translation collective",
- description = "A group translating hard books.",
- initialGiftWrapPayloadId = "sdfaer",
- mlsGroupState = "state"
- ),
- ),
- // A group that can sign, so the form renders in the state a
- // member actually meets it in rather than greyed out.
- canSign = true
- ),
- chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
- frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
- onNavigateToRouteAndPopUpInclusive = {},
- onNavigateBack = {},
- )
- }
- }
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupRelaysScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupRelaysScreen.kt
deleted file mode 100644
index 7b076c84..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupRelaysScreen.kt
+++ /dev/null
@@ -1,581 +0,0 @@
-package press.mantra.compose.ui.composable
-
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.imePadding
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
-import androidx.compose.foundation.text.input.TextFieldLineLimits
-import androidx.compose.foundation.text.input.clearText
-import androidx.compose.foundation.text.input.rememberTextFieldState
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Add
-import androidx.compose.material.icons.filled.Delete
-import androidx.compose.material.icons.filled.Draw
-import androidx.compose.material.icons.filled.Dns
-import androidx.compose.material3.BottomAppBar
-import androidx.compose.material3.BottomAppBarDefaults
-import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
-import androidx.compose.material3.ExtendedFloatingActionButton
-import androidx.compose.material3.FilterChip
-import androidx.compose.material3.FloatingActionButtonDefaults
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.OutlinedTextFieldDefaults
-import androidx.compose.material3.PrimaryScrollableTabRow
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.SnackbarHost
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Tab
-import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBar
-import androidx.compose.material3.contentColorFor
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.runtime.snapshotFlow
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.semantics.disabled
-import androidx.compose.ui.semantics.semantics
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.unit.dp
-import androidx.lifecycle.viewmodel.compose.viewModel
-import press.mantra.compose.database.model.ChatRoom
-import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.nostr.GroupRelay
-import press.mantra.compose.nostr.GroupRelayList
-import press.mantra.compose.nostr.GroupRelaySet
-import press.mantra.compose.repository.ChatRepository
-import press.mantra.compose.repository.FrostSigningRepository
-import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
-import press.mantra.compose.ui.composable.navigation.routes.Route
-import press.mantra.compose.ui.composable.widgets.Decorative
-import press.mantra.compose.ui.composable.widgets.EmptyState
-import press.mantra.compose.ui.composable.widgets.ErrorState
-import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
-import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
-import press.mantra.compose.ui.composable.widgets.NavigateBackButton
-import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
-import press.mantra.compose.ui.composable.widgets.rememberNotifier
-import press.mantra.compose.ui.theme.ConformancePreviews
-import press.mantra.compose.ui.theme.MantraTheme
-import press.mantra.compose.ui.theme.readableContent
-import press.mantra.compose.ui.theme.spacing
-import press.mantra.compose.ui.view.model.EditGroupRelaysViewModel
-import press.mantra.compose.ui.view.state.EditGroupRelaysUIState
-import com.vitorpamplona.quartz.nip01Core.core.HexKey
-import mantra.composeapp.generated.resources.Res
-import mantra.composeapp.generated.resources.add_relay
-import mantra.composeapp.generated.resources.could_not_ask_the_group_to_sign_these_relays
-import mantra.composeapp.generated.resources.edit_relays
-import mantra.composeapp.generated.resources.eg_wss_relay_example_com
-import mantra.composeapp.generated.resources.no_relays_in_this_list
-import mantra.composeapp.generated.resources.nothing_has_changed_to_propose
-import mantra.composeapp.generated.resources.propose_relays
-import mantra.composeapp.generated.resources.relay_read
-import mantra.composeapp.generated.resources.relay_set_blocked
-import mantra.composeapp.generated.resources.relay_set_blocked_purpose
-import mantra.composeapp.generated.resources.relay_set_general
-import mantra.composeapp.generated.resources.relay_set_general_purpose
-import mantra.composeapp.generated.resources.relay_set_messages
-import mantra.composeapp.generated.resources.relay_set_messages_purpose
-import mantra.composeapp.generated.resources.relay_set_search
-import mantra.composeapp.generated.resources.relay_set_search_purpose
-import mantra.composeapp.generated.resources.relay_url
-import mantra.composeapp.generated.resources.relay_write
-import mantra.composeapp.generated.resources.relays_are_public_a_group_cannot_encrypt
-import mantra.composeapp.generated.resources.that_is_not_a_relay_address
-import mantra.composeapp.generated.resources.that_relay_is_already_in_this_list
-import mantra.composeapp.generated.resources.the_group_signs_each_list_so_it_takes_a_quorum
-import mantra.composeapp.generated.resources.this_group_has_no_shared_key_to_sign_relays
-import mantra.composeapp.generated.resources.where_the_group_lives_on_nostr
-import org.jetbrains.compose.resources.StringResource
-import org.jetbrains.compose.resources.stringResource
-
-/**
- * The four relay lists a group keeps, edited as one sitting.
- *
- * A tab per list, a field to add one, a row per relay with a way to drop it, and --
- * in the one list NIP-65 gives markers to -- a read and a write chip. The shape is
- * borrowed from Wisp's relay screen, which is the interface this app was asked to
- * match, with one deliberate departure.
- *
- * **The button proposes rather than publishes.** Wisp signs with the user's own key
- * and a tab's publish button is done the moment it is pressed. A group signs with a
- * quorum, so what leaves this screen is a proposal, and the screen hands over to
- * the session it opened. Nothing has changed until enough members sign.
- *
- * And it proposes **every list that changed at once**, rather than the tab in front
- * of you. Four sessions for one sitting would ask a quorum the same question four
- * times over what is plainly one decision -- see `EditGroupRelaysViewModel`, which
- * carries the reasoning and works out what actually differs.
- */
-@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
-@Composable
-fun EditGroupRelaysScreen(
- activeUserPublicKey: HexKey,
- chatRoomId: String,
- relayHint: String?,
- initialEditGroupRelaysUIState: EditGroupRelaysUIState = EditGroupRelaysUIState.Loading,
- chatRepository: ChatRepository,
- frostSigningRepository: FrostSigningRepository,
- onNavigateToRouteAndPopUpInclusive: (Route) -> Unit,
- onNavigateBack: () -> Unit,
-) {
- val editGroupRelaysViewModel: EditGroupRelaysViewModel = viewModel(
- factory = EditGroupRelaysViewModel.factory(
- chatRoomId = chatRoomId,
- relayHint = relayHint,
- initialEditGroupRelaysUIState = initialEditGroupRelaysUIState,
- activeUserPublicKey = activeUserPublicKey,
- chatRepository = chatRepository,
- frostSigningRepository = frostSigningRepository
- )
- )
-
- // Read out here rather than in a click handler: both are composable and an
- // onClick lambda is not. The scope is the caller's so a message survives this
- // screen being replaced by the signing session.
- val notify = rememberNotifier(rememberCoroutineScope())
- val couldNotPropose = stringResource(Res.string.could_not_ask_the_group_to_sign_these_relays)
- val nothingChanged = stringResource(Res.string.nothing_has_changed_to_propose)
-
- ScreenStateTransition(editGroupRelaysViewModel.editGroupRelaysUIState) { uiState ->
- when (val editGroupRelaysUIState = uiState) {
- is EditGroupRelaysUIState.Error -> {
- // Nothing to retry: the room came from a navigation argument, and
- // reading it again with the same one fails the same way.
- ErrorState(message = editGroupRelaysUIState.message, onRetry = null)
- }
-
- is EditGroupRelaysUIState.Loaded -> {
- val urlFieldState = rememberTextFieldState()
- val selectedSet = editGroupRelaysViewModel.selectedSet
- val canSign = editGroupRelaysUIState.canSign
-
- // The editor's working copy comes from the state rather than from
- // whatever loaded it, so a screen handed a loaded state -- a
- // preview, a layout test -- fills in the same as the app does.
- // Seeding leaves edits alone, so re-running this changes nothing.
- LaunchedEffect(editGroupRelaysUIState.signed) {
- editGroupRelaysViewModel.seedWorkingCopy(editGroupRelaysUIState.signed)
- }
-
- // A refusal is about what is in the field, so it goes the moment
- // the field changes rather than sitting under a URL that has since
- // been corrected.
- LaunchedEffect(urlFieldState) {
- snapshotFlow { urlFieldState.text.toString() }
- .collect { editGroupRelaysViewModel.clearAddFailure() }
- }
-
- // M3 gives a FAB no `enabled`, so borrow the disabled colours every
- // other button in the app uses rather than inventing a shade here.
- val buttonColors = ButtonDefaults.buttonColors()
-
- Scaffold(
- snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
- modifier = Modifier.imePadding(),
- topBar = {
- TopAppBar(
- title = {
- editGroupRelaysUIState.localChatRoom.RenderChatRoomTitleText()
- },
- navigationIcon = { NavigateBackButton(onNavigateBack) },
- )
- },
- bottomBar = {
- BottomAppBar(
- actions = {},
- floatingActionButton = {
- ExtendedFloatingActionButton(
- modifier = if (canSign) {
- Modifier
- } else {
- // Looking unavailable is not being unavailable.
- Modifier.semantics { disabled() }
- },
- containerColor = if (canSign) {
- FloatingActionButtonDefaults.containerColor
- } else {
- buttonColors.disabledContainerColor
- },
- contentColor = if (canSign) {
- contentColorFor(FloatingActionButtonDefaults.containerColor)
- } else {
- buttonColors.disabledContentColor
- },
- onClick = {
- if (!canSign) return@ExtendedFloatingActionButton
-
- editGroupRelaysViewModel.proposeRelayLists(
- localChatRoom =
- editGroupRelaysUIState.localChatRoom,
- signed = editGroupRelaysUIState.signed,
- onSuccess = { sessionId ->
- // Onto the session rather than back
- // to the group. The lists have not
- // changed yet -- they change when a
- // quorum signs -- so landing on a
- // group still showing the old ones
- // would read as a failure.
- onNavigateToRouteAndPopUpInclusive.invoke(
- FrostSigningRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- sessionId = sessionId
- )
- )
- },
- // Both stay on the form with the edits
- // still in it. One is a question and
- // one is a failure, and neither is a
- // reason to throw away the typing.
- onNothingToPropose = { notify(nothingChanged) },
- onFailure = { notify(couldNotPropose) }
- )
- }
- ) {
- Icon(
- Icons.Default.Draw,
- contentDescription = "Propose relays"
- )
- Text(stringResource(Res.string.propose_relays))
- }
- }
- )
- }
- ) { innerPadding ->
- Column(
- modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
- ) {
- PrimaryScrollableTabRow(
- modifier = Modifier.padding(MaterialTheme.spacing.compactPadding),
- edgePadding = 10.dp,
- selectedTabIndex = selectedSet.ordinal,
- ) {
- GroupRelaySet.entries.forEach { set ->
- Tab(
- selected = set == selectedSet,
- onClick = { editGroupRelaysViewModel.selectSet(set) },
- text = { Text(text = stringResource(set.label())) }
- )
- }
- }
-
- Column(
- modifier = Modifier.fillMaxWidth()
- .padding(horizontal = MaterialTheme.spacing.screenMargin),
- verticalArrangement = Arrangement.spacedBy(
- MaterialTheme.spacing.itemGap
- ),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- Text(
- text = stringResource(Res.string.where_the_group_lives_on_nostr),
- style = MaterialTheme.typography.titleSmall
- )
-
- // What this tab's list is actually for. Four relay lists
- // is four questions nobody can be expected to hold apart
- // from their names alone.
- Text(
- text = stringResource(selectedSet.purpose()),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- textAlign = TextAlign.Center
- )
-
- Text(
- text = stringResource(
- Res.string.the_group_signs_each_list_so_it_takes_a_quorum
- ),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- textAlign = TextAlign.Center
- )
-
- if (selectedSet == GroupRelaySet.Blocked) {
- // Said where it matters. A person's client keeps
- // this list encrypted, and somebody blocking a relay
- // here should know the group cannot.
- Text(
- text = stringResource(
- Res.string.relays_are_public_a_group_cannot_encrypt
- ),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- textAlign = TextAlign.Center
- )
- }
-
- if (!canSign) {
- Text(
- text = stringResource(
- Res.string.this_group_has_no_shared_key_to_sign_relays
- ),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.error,
- textAlign = TextAlign.Center
- )
- }
-
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically
- ) {
- OutlinedTextField(
- modifier = Modifier.weight(1f)
- .background(BottomAppBarDefaults.containerColor),
- state = urlFieldState,
- lineLimits = TextFieldLineLimits.SingleLine,
- colors = OutlinedTextFieldDefaults.colors(
- focusedBorderColor = Color.Transparent,
- unfocusedBorderColor = Color.Transparent,
- disabledBorderColor = Color.Transparent
- ),
- leadingIcon = {
- Icon(Icons.Default.Dns, contentDescription = Decorative)
- },
- label = { Text(stringResource(Res.string.relay_url)) },
- placeholder = {
- Text(stringResource(Res.string.eg_wss_relay_example_com))
- },
- isError = editGroupRelaysViewModel.addFailure != null
- )
-
- Spacer(modifier = Modifier.width(MaterialTheme.spacing.itemGap))
-
- IconButton(
- onClick = {
- if (
- editGroupRelaysViewModel.addRelay(
- urlFieldState.text.toString()
- )
- ) {
- urlFieldState.clearText()
- }
- }
- ) {
- Icon(
- Icons.Default.Add,
- contentDescription = "Add relay"
- )
- }
- }
-
- // Named rather than silent. An add button that does
- // nothing for a URL it refuses is indistinguishable from
- // a missed tap.
- editGroupRelaysViewModel.addFailure?.let { failure ->
- Text(
- text = when (failure) {
- EditGroupRelaysViewModel.AddFailure.NotARelay ->
- stringResource(Res.string.that_is_not_a_relay_address)
- EditGroupRelaysViewModel.AddFailure.AlreadyListed ->
- stringResource(
- Res.string.that_relay_is_already_in_this_list
- )
- },
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.error,
- textAlign = TextAlign.Center
- )
- }
- }
-
- val relays = editGroupRelaysViewModel.relaysOf(selectedSet)
-
- if (relays.isEmpty()) {
- EmptyState(
- message = stringResource(Res.string.no_relays_in_this_list),
- icon = Icons.Default.Dns
- )
- } else {
- LazyColumn(
- modifier = Modifier.fillMaxWidth()
- .padding(horizontal = MaterialTheme.spacing.screenMargin)
- ) {
- items(items = relays, key = { it.url.url }) { relay ->
- RelayRow(
- relay = relay,
- hasReadWriteMarkers = selectedSet.hasReadWriteMarkers,
- onToggleRead = {
- editGroupRelaysViewModel
- .toggleRead(selectedSet, relay)
- },
- onToggleWrite = {
- editGroupRelaysViewModel
- .toggleWrite(selectedSet, relay)
- },
- onRemove = {
- editGroupRelaysViewModel
- .removeRelay(selectedSet, relay)
- }
- )
- }
- }
- }
- }
- }
- }
-
- EditGroupRelaysUIState.Loading -> {
- Column(
- modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.screenMargin),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap)
- ) {
- Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap))
-
- Text(
- text = stringResource(Res.string.edit_relays),
- style = MaterialTheme.typography.bodyLarge,
- textAlign = TextAlign.Center
- )
-
- LoadingDataIndicator(fillScreen = false)
- }
- }
- }
- }
-
- LaunchedEffect(true) {
- if (initialEditGroupRelaysUIState == EditGroupRelaysUIState.Loading) {
- editGroupRelaysViewModel.initiateEditGroupRelays()
- }
- }
-}
-
-/**
- * The tab's name, and the sentence saying what its list decides.
- *
- * Kept here rather than on [GroupRelaySet] so the enum stays what it is -- four
- * nostr kinds and how to read and write them -- with no catalogue behind it. A
- * `when` rather than a field on each entry, so adding a set is a compile error here
- * rather than a tab with no name.
- */
-private fun GroupRelaySet.label(): StringResource = when (this) {
- GroupRelaySet.General -> Res.string.relay_set_general
- GroupRelaySet.Messages -> Res.string.relay_set_messages
- GroupRelaySet.Search -> Res.string.relay_set_search
- GroupRelaySet.Blocked -> Res.string.relay_set_blocked
-}
-
-private fun GroupRelaySet.purpose(): StringResource = when (this) {
- GroupRelaySet.General -> Res.string.relay_set_general_purpose
- GroupRelaySet.Messages -> Res.string.relay_set_messages_purpose
- GroupRelaySet.Search -> Res.string.relay_set_search_purpose
- GroupRelaySet.Blocked -> Res.string.relay_set_blocked_purpose
-}
-
-/**
- * One relay of one list.
- *
- * The read and write chips only in [hasReadWriteMarkers], which is NIP-65 alone --
- * the other three kinds have a relay in the list or not, and a chip there would
- * offer a distinction the event cannot carry.
- */
-@Composable
-private fun RelayRow(
- relay: GroupRelay,
- hasReadWriteMarkers: Boolean,
- onToggleRead: () -> Unit,
- onToggleWrite: () -> Unit,
- onRemove: () -> Unit
-) {
- Row(
- modifier = Modifier.fillMaxWidth().padding(vertical = MaterialTheme.spacing.relatedGap),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Column(
- modifier = Modifier.weight(1f),
- verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)
- ) {
- Text(
- // The host rather than the whole URL: every relay here starts
- // `wss://` -- nothing else is allowed in -- so the scheme is four
- // characters of nothing in front of the part that differs.
- text = relay.displayUrl(),
- style = MaterialTheme.typography.bodyMedium
- )
-
- if (hasReadWriteMarkers) {
- Row(
- horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap)
- ) {
- FilterChip(
- selected = relay.read,
- onClick = onToggleRead,
- label = { Text(stringResource(Res.string.relay_read)) }
- )
- FilterChip(
- selected = relay.write,
- onClick = onToggleWrite,
- label = { Text(stringResource(Res.string.relay_write)) }
- )
- }
- }
- }
-
- IconButton(onClick = onRemove) {
- Icon(
- Icons.Default.Delete,
- contentDescription = "Remove relay",
- tint = MaterialTheme.colorScheme.error
- )
- }
- }
-}
-
-@ConformancePreviews
-@Composable
-private fun EditGroupRelaysScreenPreview() {
- MantraTheme {
- Surface(modifier = Modifier.fillMaxSize()) {
- EditGroupRelaysScreen(
- activeUserPublicKey = "",
- chatRoomId = "publicKey",
- relayHint = null,
- initialEditGroupRelaysUIState = EditGroupRelaysUIState.Loaded(
- localChatRoom = LocalChatRoom(
- chatRoom = ChatRoom(
- id = "publicKey",
- userPublicKey = "",
- subject = "Translation room",
- description = "A group translating hard books.",
- initialGiftWrapPayloadId = "sdfaer",
- mlsGroupState = "state"
- ),
- ),
- // Unsigned lists, which is a group opening this for the first
- // time -- so the editor fills in from `GroupRelaySet.defaults`
- // and the preview shows the relay a new group actually starts
- // with rather than an empty tab.
- signed = GroupRelayList.allAmong(emptyList(), "publicKey"),
- // A group that can sign, so the form renders in the state a
- // member actually meets it in rather than greyed out.
- canSign = true
- ),
- chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
- frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
- onNavigateToRouteAndPopUpInclusive = {},
- onNavigateBack = {},
- )
- }
- }
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupCuratedEntriesScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupCuratedEntriesScreen.kt
deleted file mode 100644
index 7edd477e..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupCuratedEntriesScreen.kt
+++ /dev/null
@@ -1,365 +0,0 @@
-package press.mantra.compose.ui.composable
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.CheckCircle
-import androidx.compose.material3.Card
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Icon
-import androidx.compose.material3.ListItem
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.SnackbarHost
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBar
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.lifecycle.viewmodel.compose.viewModel
-import com.vitorpamplona.quartz.nip01Core.core.HexKey
-import mantra.composeapp.generated.resources.Res
-import mantra.composeapp.generated.resources.curated_entries
-import mantra.composeapp.generated.resources.in_list
-import mantra.composeapp.generated.resources.signed_by_the_group_on
-import mantra.composeapp.generated.resources.suggested_by
-import mantra.composeapp.generated.resources.this_group_has_not_accepted_anything_into_a_list_yet
-import org.jetbrains.compose.resources.stringResource
-import press.mantra.compose.database.model.ChatRoom
-import press.mantra.compose.database.model.GroupSignedEvent
-import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.extensions.shortened
-import press.mantra.compose.extensions.toFormattedTimeAndDateString
-import press.mantra.compose.nostr.GroupCuratedEntry
-import press.mantra.compose.nostr.GroupCuratedSchema
-import press.mantra.compose.nostr.curated.CuratedEntry
-import press.mantra.compose.nostr.curated.CuratedEntryEvent
-import press.mantra.compose.nostr.curated.CuratedField
-import press.mantra.compose.nostr.curated.CuratedSchema
-import press.mantra.compose.nostr.curated.CuratedSchemaEvent
-import press.mantra.compose.nostr.curated.CuratedVisibility
-import press.mantra.compose.repository.ChatRepository
-import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute
-import press.mantra.compose.ui.composable.navigation.routes.Route
-import press.mantra.compose.ui.composable.widgets.Decorative
-import press.mantra.compose.ui.composable.widgets.EmptyState
-import press.mantra.compose.ui.composable.widgets.ErrorState
-import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
-import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
-import press.mantra.compose.ui.composable.widgets.NavigateBackButton
-import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
-import press.mantra.compose.ui.composable.widgets.buttons.BroadcastButton
-import press.mantra.compose.ui.theme.ConformancePreviews
-import press.mantra.compose.ui.theme.MantraTheme
-import press.mantra.compose.ui.theme.readableContent
-import press.mantra.compose.ui.theme.spacing
-import press.mantra.compose.ui.view.model.GroupCuratedEntriesViewModel
-import press.mantra.compose.ui.view.state.GroupCuratedEntriesUIState
-import kotlin.time.Instant
-
-/**
- * The entries the group has accepted into the lists it curates, newest first.
- *
- * Pushed from its row on the group's screen, which counts the entries and
- * nothing more. This is where they are read: one card per entry with a
- * broadcast under it. No gate on either, because reading what the group
- * decided takes no share of the key, and neither does repeating its signature
- * to a relay.
- *
- * There is no way in to add one here, because an entry is accepted from the
- * queue it was suggested to, which is reached from its schema's card on the
- * `GroupCuratedSchemasScreen`. The empty state says as much.
- */
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun GroupCuratedEntriesScreen(
- activeUserPublicKey: HexKey,
- chatRoomId: String,
- relayHint: String?,
- initialGroupCuratedEntriesUIState: GroupCuratedEntriesUIState =
- GroupCuratedEntriesUIState.Loading,
- chatRepository: ChatRepository,
- onNavigateToRoute: (Route) -> Unit,
- onNavigateBack: () -> Unit,
-) {
- val groupCuratedEntriesViewModel: GroupCuratedEntriesViewModel = viewModel(
- factory = GroupCuratedEntriesViewModel.factory(
- chatRoomId = chatRoomId,
- initialGroupCuratedEntriesUIState = initialGroupCuratedEntriesUIState,
- chatRepository = chatRepository,
- )
- )
-
- LaunchedEffect(true) {
- if (initialGroupCuratedEntriesUIState == GroupCuratedEntriesUIState.Loading) {
- groupCuratedEntriesViewModel.initiate()
- }
- }
-
- Scaffold(
- snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
- topBar = {
- TopAppBar(
- title = {
- Text(
- text = stringResource(Res.string.curated_entries),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- },
- navigationIcon = { NavigateBackButton(onNavigateBack) }
- )
- }
- ) { padding ->
- ScreenStateTransition(groupCuratedEntriesViewModel.groupCuratedEntriesUIState) { uiState ->
- when (val state = uiState) {
- is GroupCuratedEntriesUIState.Error -> {
- // Nothing to retry: the room came from a navigation argument,
- // and reading it again with the same one fails the same way.
- ErrorState(
- modifier = Modifier.padding(padding),
- message = state.message,
- onRetry = null
- )
- }
-
- GroupCuratedEntriesUIState.Loading -> {
- Column(
- modifier = Modifier.fillMaxWidth().padding(padding)
- .padding(MaterialTheme.spacing.screenMargin),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap)
- ) {
- Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap))
-
- LoadingDataIndicator(fillScreen = false)
- }
- }
-
- is GroupCuratedEntriesUIState.Loaded -> {
- LazyColumn(
- modifier = Modifier.padding(padding).readableContent().fillMaxSize()
- .padding(MaterialTheme.spacing.space250),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
- ) {
- if (state.groupCuratedEntries.isEmpty()) {
- item {
- EmptyState(
- message = stringResource(
- Res.string.this_group_has_not_accepted_anything_into_a_list_yet
- )
- )
- }
- } else {
- items(
- items = state.groupCuratedEntries,
- key = { accepted -> accepted.signedEvent.id }
- ) { accepted ->
- // The card and its broadcast as one item, so the
- // button stays under the entry it sends however
- // the list is arranged.
- Column(
- modifier = Modifier.fillMaxWidth(),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- GroupCuratedEntryCard(accepted = accepted)
-
- BroadcastButton(
- onClick = {
- onNavigateToRoute.invoke(
- BroadcastGroupSignedEventRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = relayHint,
- signedEventId = accepted.signedEvent.id
- )
- )
- }
- )
- }
- }
- }
- }
- }
- }
- }
- }
-}
-
-/**
- * One entry the group accepted into a list it curates, as the list's own word.
- *
- * The title first, because every list has one; then which list, since a group
- * may curate several and an entry says nothing about its list on its face;
- * then the glance the queue row gives -- the fields that say what the entry
- * *is*, labelled as the schema labels them, and not the links -- and the body
- * where the list has one. The last line credits the suggester, when there was
- * one, and dates the group's decision, which is the fact a member reading the
- * screen came for.
- *
- * Not a way in to anything: the entry is revised from the queue it was
- * suggested to, where the suggestion it came from and the "Curated" mark sit
- * side by side, and the card's one action is the broadcast under it.
- */
-@Composable
-private fun GroupCuratedEntryCard(accepted: GroupCuratedEntry) {
- val schema = accepted.list.schema
- val entry = accepted.entry
-
- Card(modifier = Modifier.fillMaxWidth()) {
- ListItem(
- leadingContent = {
- Icon(
- Icons.Default.CheckCircle,
- contentDescription = Decorative,
- tint = MaterialTheme.colorScheme.primary
- )
- },
- headlineContent = {
- Text(
- text = accepted.title,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis
- )
- },
- supportingContent = {
- Column(
- verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)
- ) {
- Text(
- text = stringResource(Res.string.in_list, schema.displayName()),
- style = MaterialTheme.typography.labelMedium,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
-
- glanceOf(entry, schema)?.let {
- Text(
- text = it,
- style = MaterialTheme.typography.bodySmall,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis
- )
- }
-
- bodyOf(entry, schema)?.let {
- Text(
- text = it,
- maxLines = 3,
- overflow = TextOverflow.Ellipsis
- )
- }
-
- Text(
- text = listOfNotNull(
- entry.source?.let { stringResource(Res.string.suggested_by, it.pubKey.shortened()) },
- stringResource(
- Res.string.signed_by_the_group_on,
- accepted.signedAt.toFormattedTimeAndDateString()
- )
- ).joinToString(" · "),
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- }
- )
- }
-}
-
-@ConformancePreviews
-@Composable
-private fun GroupCuratedEntriesScreenPreview() {
- val chatRoomId = "d".repeat(64)
-
- // Built rather than read out of signed events: the reading checks a real
- // signature, and there is no real one here.
- val films = GroupCuratedSchema(
- signedEvent = GroupSignedEvent(
- id = "c".repeat(64),
- chatRoomId = chatRoomId,
- publicKey = chatRoomId,
- kind = CuratedSchemaEvent.KIND,
- tags = emptyArray(),
- content = "",
- signature = "f".repeat(128),
- createdAt = Instant.fromEpochSeconds(1_700_000_000)
- ),
- schema = CuratedSchema(
- identifier = "films",
- title = "Films worth translating suggestion",
- name = "Films worth translating",
- description = "Things the group would translate, with a link to each.",
- visibility = CuratedVisibility.Public,
- fields = listOf(CuratedField.IDENTIFIER, CuratedField.TITLE),
- relays = listOf("wss://relay.one.example")
- )
- )
-
- MantraTheme {
- Surface(modifier = Modifier.fillMaxSize()) {
- GroupCuratedEntriesScreen(
- activeUserPublicKey = "a".repeat(64),
- chatRoomId = chatRoomId,
- relayHint = null,
- initialGroupCuratedEntriesUIState = GroupCuratedEntriesUIState.Loaded(
- localChatRoom = LocalChatRoom(
- chatRoom = ChatRoom(
- id = chatRoomId,
- userPublicKey = "a".repeat(64),
- subject = "Translation room",
- description = null,
- initialGiftWrapPayloadId = "sdfaer",
- mlsGroupState = "state"
- )
- ),
- groupCuratedEntries = listOf(
- GroupCuratedEntry(
- signedEvent = GroupSignedEvent(
- id = "e".repeat(64),
- chatRoomId = chatRoomId,
- publicKey = chatRoomId,
- kind = CuratedEntryEvent.CANONICAL_KIND,
- tags = arrayOf(
- arrayOf("d", "magic-money"),
- arrayOf("title", "Magic Money")
- ),
- content = "",
- signature = "f".repeat(128),
- createdAt = Instant.fromEpochSeconds(1_700_000_900)
- ),
- list = films,
- entry = CuratedEntry(
- kind = CuratedEntryEvent.CANONICAL_KIND,
- id = "e".repeat(64),
- pubKey = chatRoomId,
- createdAt = 1_700_000_900,
- identifier = "magic-money",
- title = "Magic Money",
- values = mapOf(
- "identifier" to listOf("magic-money"),
- "title" to listOf("Magic Money"),
- ),
- source = null
- )
- )
- )
- ),
- chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
- onNavigateToRoute = {},
- onNavigateBack = {},
- )
- }
- }
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupCuratedSchemasScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupCuratedSchemasScreen.kt
deleted file mode 100644
index 5d2f74e7..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupCuratedSchemasScreen.kt
+++ /dev/null
@@ -1,442 +0,0 @@
-package press.mantra.compose.ui.composable
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.ExperimentalLayoutApi
-import androidx.compose.foundation.layout.FlowRow
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
-import androidx.compose.material.icons.filled.Ballot
-import androidx.compose.material.icons.filled.Edit
-import androidx.compose.material.icons.filled.Inbox
-import androidx.compose.material3.Card
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Icon
-import androidx.compose.material3.ListItem
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.SnackbarHost
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.material3.TopAppBar
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.lifecycle.viewmodel.compose.viewModel
-import com.vitorpamplona.quartz.nip01Core.core.HexKey
-import mantra.composeapp.generated.resources.Res
-import mantra.composeapp.generated.resources.add_schema
-import mantra.composeapp.generated.resources.curated_schemas
-import mantra.composeapp.generated.resources.fields_colon
-import mantra.composeapp.generated.resources.signed_by_the_group_on
-import mantra.composeapp.generated.resources.this_group_has_not_published_a_curated_schema_yet
-import mantra.composeapp.generated.resources.view_suggestions
-import org.jetbrains.compose.resources.stringResource
-import press.mantra.compose.database.model.ChatRoom
-import press.mantra.compose.database.model.GroupSignedEvent
-import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.extensions.toFormattedTimeAndDateString
-import press.mantra.compose.nostr.GroupCuratedSchema
-import press.mantra.compose.nostr.curated.CuratedField
-import press.mantra.compose.nostr.curated.CuratedSchema
-import press.mantra.compose.nostr.curated.CuratedSchemaEvent
-import press.mantra.compose.nostr.curated.CuratedVisibility
-import press.mantra.compose.repository.ChatRepository
-import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute
-import press.mantra.compose.ui.composable.navigation.routes.CuratedSuggestionListRoute
-import press.mantra.compose.ui.composable.navigation.routes.EditGroupCuratedSchemaRoute
-import press.mantra.compose.ui.composable.navigation.routes.Route
-import press.mantra.compose.ui.composable.widgets.Decorative
-import press.mantra.compose.ui.composable.widgets.EmptyState
-import press.mantra.compose.ui.composable.widgets.ErrorState
-import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
-import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
-import press.mantra.compose.ui.composable.widgets.NavigateBackButton
-import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
-import press.mantra.compose.ui.composable.widgets.buttons.BroadcastButton
-import press.mantra.compose.ui.theme.ConformancePreviews
-import press.mantra.compose.ui.theme.MantraTheme
-import press.mantra.compose.ui.theme.readableContent
-import press.mantra.compose.ui.theme.spacing
-import press.mantra.compose.ui.view.model.GroupCuratedSchemasViewModel
-import press.mantra.compose.ui.view.state.GroupCuratedSchemasUIState
-import kotlin.time.Instant
-
-/**
- * The lists the group curates, as the schemas it signed for them.
- *
- * Pushed from its row on the group's screen, which counts the schemas and
- * nothing more. This is where they are read: one card per list, most recently
- * signed first, each with its queue and its broadcast under it, and -- for a
- * member holding a share of the key -- the way into the editor, on the card
- * for a revision and at the end for a new list.
- *
- * A schema exists to be read by people who were never in the room, so a member
- * without a share reads it like anybody else, and reads its queue, which the
- * group did not write. What they cannot do is propose a revision -- neither a
- * new schema nor an edit -- since proposing is a signature by the group and
- * `proposeSigningBatch` throws for a device holding no share.
- */
-@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
-@Composable
-fun GroupCuratedSchemasScreen(
- activeUserPublicKey: HexKey,
- chatRoomId: String,
- relayHint: String?,
- initialGroupCuratedSchemasUIState: GroupCuratedSchemasUIState =
- GroupCuratedSchemasUIState.Loading,
- chatRepository: ChatRepository,
- onNavigateToRoute: (Route) -> Unit,
- onNavigateBack: () -> Unit,
-) {
- val groupCuratedSchemasViewModel: GroupCuratedSchemasViewModel = viewModel(
- factory = GroupCuratedSchemasViewModel.factory(
- chatRoomId = chatRoomId,
- initialGroupCuratedSchemasUIState = initialGroupCuratedSchemasUIState,
- chatRepository = chatRepository,
- )
- )
-
- LaunchedEffect(true) {
- if (initialGroupCuratedSchemasUIState == GroupCuratedSchemasUIState.Loading) {
- groupCuratedSchemasViewModel.initiate()
- }
- }
-
- Scaffold(
- snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
- topBar = {
- TopAppBar(
- title = {
- Text(
- text = stringResource(Res.string.curated_schemas),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- },
- navigationIcon = { NavigateBackButton(onNavigateBack) }
- )
- }
- ) { padding ->
- ScreenStateTransition(groupCuratedSchemasViewModel.groupCuratedSchemasUIState) { uiState ->
- when (val state = uiState) {
- is GroupCuratedSchemasUIState.Error -> {
- // Nothing to retry: the room came from a navigation argument,
- // and reading it again with the same one fails the same way.
- ErrorState(
- modifier = Modifier.padding(padding),
- message = state.message,
- onRetry = null
- )
- }
-
- GroupCuratedSchemasUIState.Loading -> {
- Column(
- modifier = Modifier.fillMaxWidth().padding(padding)
- .padding(MaterialTheme.spacing.screenMargin),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap)
- ) {
- Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap))
-
- LoadingDataIndicator(fillScreen = false)
- }
- }
-
- is GroupCuratedSchemasUIState.Loaded -> {
- // The one way into the editor, for a revision and for a new
- // list alike: the editor takes the identifier of the schema
- // to revise, and null for one that does not exist yet.
- val edit: (String?) -> Unit = { identifier ->
- onNavigateToRoute.invoke(
- EditGroupCuratedSchemaRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = relayHint,
- identifier = identifier
- )
- )
- }
-
- LazyColumn(
- modifier = Modifier.padding(padding).readableContent().fillMaxSize()
- .padding(MaterialTheme.spacing.space250),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
- ) {
- if (state.groupCuratedSchemas.isEmpty()) {
- item {
- EmptyState(
- message = stringResource(
- Res.string.this_group_has_not_published_a_curated_schema_yet
- ),
- action = if (state.canSign) {
- { AddSchemaButton(onClick = { edit(null) }) }
- } else {
- null
- }
- )
- }
- } else {
- items(
- items = state.groupCuratedSchemas,
- key = { curated -> curated.signedEvent.id }
- ) { curated ->
- // The card and its queue as one item, so the
- // button stays under the schema it is about
- // however the list is arranged.
- Column(
- modifier = Modifier.fillMaxWidth(),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- GroupCuratedSchemaCard(
- curatedSchema = curated,
- // Into the editor for a member who could
- // propose a revision, and a plain card for
- // one who could not: the screen's gate,
- // applied to the card because each schema
- // is edited on its own.
- onEdit = if (state.canSign) {
- { edit(curated.identifier) }
- } else {
- null
- }
- )
-
- // Under every schema and behind no gate,
- // because the queue is the one thing here
- // the group did not write: a schema exists
- // to be answered by strangers, and reading
- // the answers takes no share of the key.
- // Per card because each list has its own
- // queue.
- //
- // Beside the broadcast button rather than
- // over it, wrapping where the two will not
- // fit on one line: both are about this one
- // card and a column of buttons under every
- // schema would read as a menu.
- FlowRow(
- horizontalArrangement = Arrangement.spacedBy(
- MaterialTheme.spacing.itemGap,
- Alignment.CenterHorizontally
- )
- ) {
- TextButton(
- onClick = {
- onNavigateToRoute.invoke(
- CuratedSuggestionListRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = relayHint,
- identifier = curated.identifier
- )
- )
- }
- ) {
- Icon(Icons.Default.Inbox, contentDescription = Decorative)
-
- Spacer(
- modifier = Modifier.width(MaterialTheme.spacing.space125)
- )
-
- Text(stringResource(Res.string.view_suggestions))
- }
-
- BroadcastButton(
- onClick = {
- onNavigateToRoute.invoke(
- BroadcastGroupSignedEventRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = relayHint,
- signedEventId = curated.signedEvent.id
- )
- )
- }
- )
- }
- }
- }
-
- if (state.canSign) {
- item {
- AddSchemaButton(onClick = { edit(null) })
- }
- }
- }
- }
- }
- }
- }
- }
-}
-
-/**
- * One list the group curates, as the schema it signed for it.
- *
- * Named by the list -- the domain where it claims one, else the name -- because
- * that is what a suggester sees the list called; the title under it is the label
- * the event carries. The fields are listed by label rather than counted, since
- * what a reader wants to know about a list is what an entry in it asks for, and
- * "13 fields" answers a different question.
- *
- * The visibility is on the last line beside the date, because who may suggest is
- * the one fact on the card a member might act on -- a closed list is one they
- * would have to be named on.
- *
- * [onEdit] null draws a card that cannot be opened. It is the screen's gate
- * applied per card: a schema is edited on its own, so the way in has to be on it.
- */
-@Composable
-private fun GroupCuratedSchemaCard(
- curatedSchema: GroupCuratedSchema,
- onEdit: (() -> Unit)?
-) {
- val content: @Composable () -> Unit = {
- ListItem(
- leadingContent = {
- Icon(Icons.Default.Ballot, contentDescription = Decorative)
- },
- trailingContent = onEdit?.let {
- { Icon(Icons.Default.Edit, contentDescription = "Edit schema") }
- },
- headlineContent = {
- Text(
- text = curatedSchema.schema.displayName(),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- },
- supportingContent = {
- Column(
- verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)
- ) {
- Text(
- text = curatedSchema.schema.title,
- style = MaterialTheme.typography.labelMedium,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
-
- Text(
- text = curatedSchema.schema.description,
- maxLines = 3,
- overflow = TextOverflow.Ellipsis
- )
-
- Text(
- text = stringResource(
- Res.string.fields_colon,
- curatedSchema.schema.fields.joinToString { it.labelOrName() }
- ),
- style = MaterialTheme.typography.bodySmall,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis
- )
-
- Text(
- text = listOfNotNull(
- curatedSchema.schema.visibility?.let { stringResource(it.label()) },
- stringResource(
- Res.string.signed_by_the_group_on,
- curatedSchema.signedAt.toFormattedTimeAndDateString()
- )
- ).joinToString(" · "),
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- }
- )
- }
-
- if (onEdit != null) {
- Card(modifier = Modifier.fillMaxWidth(), onClick = onEdit) { content() }
- } else {
- Card(modifier = Modifier.fillMaxWidth()) { content() }
- }
-}
-
-/**
- * The way into `EditGroupCuratedSchemaScreen` for a list that does not exist
- * yet. The icon is decorative because the label is beside it.
- */
-@Composable
-private fun AddSchemaButton(onClick: () -> Unit) {
- TextButton(onClick = onClick) {
- Icon(Icons.AutoMirrored.Filled.PlaylistAdd, contentDescription = Decorative)
-
- Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125))
-
- Text(stringResource(Res.string.add_schema))
- }
-}
-
-@ConformancePreviews
-@Composable
-private fun GroupCuratedSchemasScreenPreview() {
- val chatRoomId = "d".repeat(64)
-
- MantraTheme {
- Surface(modifier = Modifier.fillMaxSize()) {
- GroupCuratedSchemasScreen(
- activeUserPublicKey = "a".repeat(64),
- chatRoomId = chatRoomId,
- relayHint = null,
- initialGroupCuratedSchemasUIState = GroupCuratedSchemasUIState.Loaded(
- localChatRoom = LocalChatRoom(
- chatRoom = ChatRoom(
- id = chatRoomId,
- userPublicKey = "a".repeat(64),
- subject = "Translation room",
- description = null,
- initialGiftWrapPayloadId = "sdfaer",
- mlsGroupState = "state"
- )
- ),
- // Built rather than read out of a signed event: the reading
- // checks a real signature, and there is no real one here.
- groupCuratedSchemas = listOf(
- GroupCuratedSchema(
- signedEvent = GroupSignedEvent(
- id = "c".repeat(64),
- chatRoomId = chatRoomId,
- publicKey = chatRoomId,
- kind = CuratedSchemaEvent.KIND,
- tags = emptyArray(),
- content = "",
- signature = "f".repeat(128),
- createdAt = Instant.fromEpochSeconds(1_700_000_000)
- ),
- schema = CuratedSchema(
- identifier = "films",
- title = "Films worth translating suggestion",
- name = "Films worth translating",
- description = "Things the group would translate, with a link to each.",
- visibility = CuratedVisibility.Public,
- fields = listOf(CuratedField.IDENTIFIER, CuratedField.TITLE),
- relays = listOf("wss://relay.one.example")
- )
- )
- ),
- canSign = true
- ),
- chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
- onNavigateToRoute = {},
- onNavigateBack = {},
- )
- }
- }
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileScreen.kt
deleted file mode 100644
index f60b7414..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileScreen.kt
+++ /dev/null
@@ -1,353 +0,0 @@
-package press.mantra.compose.ui.composable
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Badge
-import androidx.compose.material3.Card
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Icon
-import androidx.compose.material3.ListItem
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.SnackbarHost
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.material3.TopAppBar
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.lifecycle.viewmodel.compose.viewModel
-import com.vitorpamplona.quartz.nip01Core.core.HexKey
-import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
-import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
-import mantra.composeapp.generated.resources.Res
-import mantra.composeapp.generated.resources.edit_nostr_profile
-import mantra.composeapp.generated.resources.no_name_on_this_profile
-import mantra.composeapp.generated.resources.nostr_profile
-import mantra.composeapp.generated.resources.signed_by_the_group_on
-import mantra.composeapp.generated.resources.this_group_has_not_said_anything_about_itself
-import org.jetbrains.compose.resources.stringResource
-import press.mantra.compose.database.model.ChatRoom
-import press.mantra.compose.database.model.GroupSignedEvent
-import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.extensions.toFormattedTimeAndDateString
-import press.mantra.compose.nostr.GroupNostrProfile
-import press.mantra.compose.repository.ChatRepository
-import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute
-import press.mantra.compose.ui.composable.navigation.routes.EditGroupNostrProfileRoute
-import press.mantra.compose.ui.composable.navigation.routes.Route
-import press.mantra.compose.ui.composable.widgets.Decorative
-import press.mantra.compose.ui.composable.widgets.EmptyState
-import press.mantra.compose.ui.composable.widgets.ErrorState
-import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
-import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
-import press.mantra.compose.ui.composable.widgets.NavigateBackButton
-import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
-import press.mantra.compose.ui.composable.widgets.buttons.BroadcastButton
-import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar
-import press.mantra.compose.ui.theme.ConformancePreviews
-import press.mantra.compose.ui.theme.MantraTheme
-import press.mantra.compose.ui.theme.readableContent
-import press.mantra.compose.ui.theme.spacing
-import press.mantra.compose.ui.view.model.GroupNostrProfileViewModel
-import press.mantra.compose.ui.view.state.GroupNostrProfileUIState
-import kotlin.time.Instant
-
-/**
- * What the group says about itself on nostr, as the group signed it.
- *
- * Pushed from its row on the group's screen, which names the profile and
- * nothing more. This is where it is read: the card, the broadcast under it,
- * and -- for a member holding a share of the key -- the way into the editor.
- * A group that has said nothing is told so, and offered the editor from the
- * same place, since describing itself is the one thing to do about that.
- *
- * The profile is worth reading either way; proposing one is a signature by the
- * group, and `proposeSigningBatch` throws for a device holding no share of the
- * key. The editor is hidden rather than disabled, the way every gated way in
- * on the group's screen is.
- */
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun GroupNostrProfileScreen(
- activeUserPublicKey: HexKey,
- chatRoomId: String,
- relayHint: String?,
- initialGroupNostrProfileUIState: GroupNostrProfileUIState = GroupNostrProfileUIState.Loading,
- chatRepository: ChatRepository,
- onNavigateToRoute: (Route) -> Unit,
- onNavigateBack: () -> Unit,
-) {
- val groupNostrProfileViewModel: GroupNostrProfileViewModel = viewModel(
- factory = GroupNostrProfileViewModel.factory(
- chatRoomId = chatRoomId,
- initialGroupNostrProfileUIState = initialGroupNostrProfileUIState,
- chatRepository = chatRepository,
- )
- )
-
- LaunchedEffect(true) {
- if (initialGroupNostrProfileUIState == GroupNostrProfileUIState.Loading) {
- groupNostrProfileViewModel.initiate()
- }
- }
-
- Scaffold(
- snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
- topBar = {
- TopAppBar(
- title = {
- Text(
- text = stringResource(Res.string.nostr_profile),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- },
- navigationIcon = { NavigateBackButton(onNavigateBack) }
- )
- }
- ) { padding ->
- ScreenStateTransition(groupNostrProfileViewModel.groupNostrProfileUIState) { uiState ->
- when (val state = uiState) {
- is GroupNostrProfileUIState.Error -> {
- // Nothing to retry: the room came from a navigation argument,
- // and reading it again with the same one fails the same way.
- ErrorState(
- modifier = Modifier.padding(padding),
- message = state.message,
- onRetry = null
- )
- }
-
- GroupNostrProfileUIState.Loading -> {
- Column(
- modifier = Modifier.fillMaxWidth().padding(padding)
- .padding(MaterialTheme.spacing.screenMargin),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap)
- ) {
- Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap))
-
- LoadingDataIndicator(fillScreen = false)
- }
- }
-
- is GroupNostrProfileUIState.Loaded -> {
- val edit: () -> Unit = {
- onNavigateToRoute.invoke(
- EditGroupNostrProfileRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = relayHint
- )
- )
- }
-
- LazyColumn(
- modifier = Modifier.padding(padding).readableContent().fillMaxSize()
- .padding(MaterialTheme.spacing.space250),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
- ) {
- item {
- state.groupNostrProfile?.let { groupNostrProfile ->
- // The card and its broadcast button as one item,
- // so the button stays under the event it sends
- // however the list is arranged.
- Column(
- modifier = Modifier.fillMaxWidth(),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- GroupNostrProfileCard(
- groupNostrProfile = groupNostrProfile,
- publicKey = chatRoomId
- )
-
- BroadcastButton(
- onClick = {
- onNavigateToRoute.invoke(
- BroadcastGroupSignedEventRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = relayHint,
- signedEventId = groupNostrProfile.signedEvent.id
- )
- )
- }
- )
- }
- } ?: EmptyState(
- message = stringResource(
- Res.string.this_group_has_not_said_anything_about_itself
- ),
- // The way to fill the absence, where this device
- // could: describing the group is a signature by it.
- action = if (state.canSign) {
- { EditNostrProfileButton(onClick = edit) }
- } else {
- null
- }
- )
- }
-
- if (state.canSign && state.groupNostrProfile != null) {
- item {
- EditNostrProfileButton(onClick = edit)
- }
- }
- }
- }
- }
- }
- }
-}
-
-/**
- * What the group says about itself on nostr, as the group signed it.
- *
- * A preview rather than a profile screen: the name, the picture, the nostr address
- * and the first lines of the group's own account of itself, which is what a member
- * opening the group's details came here to check. The whole thing is one signed
- * event and the proposal list is where it can be read out in full.
- *
- * **The picture and the name come from the metadata; the tint comes from the key.**
- * [publicKey] is the room's id, which is the pubkey the group signs as, so a group
- * with no picture yet still gets the colour it has everywhere else in the app.
- *
- * The signing date is here because a group's profile is standing state with no
- * other clue to its age -- and because the alternative reading, that this arrived
- * from a relay a moment ago, is exactly what has not happened. Nothing on this card
- * was published by being signed -- see `GroupNostrProfile` -- and the broadcast
- * button under it is how it gets to a relay.
- */
-@Composable
-private fun GroupNostrProfileCard(
- groupNostrProfile: GroupNostrProfile,
- publicKey: HexKey
-) {
- Card(modifier = Modifier.fillMaxWidth()) {
- ListItem(
- leadingContent = {
- ProfileAvatar(
- publicKey = publicKey,
- picture = groupNostrProfile.picture(),
- name = groupNostrProfile.name()
- )
- },
- headlineContent = {
- Text(
- // Named rather than falling back to the pubkey: the signing key
- // row on the group's screen already shows that, and showing it
- // twice would say the group has a name when it has not.
- text = groupNostrProfile.name()
- ?: stringResource(Res.string.no_name_on_this_profile),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- },
- supportingContent = {
- Column(
- verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)
- ) {
- groupNostrProfile.nip05()?.let {
- Text(
- text = it,
- style = MaterialTheme.typography.labelMedium,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- }
-
- groupNostrProfile.about()?.let {
- Text(
- text = it,
- maxLines = 3,
- overflow = TextOverflow.Ellipsis
- )
- }
-
- Text(
- text = stringResource(
- Res.string.signed_by_the_group_on,
- groupNostrProfile.signedAt.toFormattedTimeAndDateString()
- ),
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- }
- )
- }
-}
-
-/** The way into `EditGroupNostrProfileScreen`. The icon is decorative because the label is beside it. */
-@Composable
-private fun EditNostrProfileButton(onClick: () -> Unit) {
- TextButton(onClick = onClick) {
- Icon(Icons.Default.Badge, contentDescription = Decorative)
-
- Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125))
-
- Text(stringResource(Res.string.edit_nostr_profile))
- }
-}
-
-@ConformancePreviews
-@Composable
-private fun GroupNostrProfileScreenPreview() {
- MantraTheme {
- Surface(modifier = Modifier.fillMaxSize()) {
- GroupNostrProfileScreen(
- activeUserPublicKey = "a".repeat(64),
- chatRoomId = "d".repeat(64),
- relayHint = null,
- initialGroupNostrProfileUIState = GroupNostrProfileUIState.Loaded(
- localChatRoom = LocalChatRoom(
- chatRoom = ChatRoom(
- id = "d".repeat(64),
- userPublicKey = "a".repeat(64),
- subject = "Translation room",
- description = null,
- initialGiftWrapPayloadId = "sdfaer",
- mlsGroupState = "state"
- )
- ),
- // Built directly rather than read out of a signed event: the
- // reading checks a signature, and there is no real one here.
- groupNostrProfile = GroupNostrProfile(
- signedEvent = GroupSignedEvent(
- id = "e".repeat(64),
- chatRoomId = "d".repeat(64),
- publicKey = "d".repeat(64),
- kind = MetadataEvent.KIND,
- tags = emptyArray(),
- content = "",
- signature = "f".repeat(128),
- createdAt = Instant.fromEpochSeconds(1_700_000_000)
- ),
- metadata = UserMetadata().apply {
- displayName = "Translation collective"
- about = "A group translating hard books into Sesotho."
- nip05 = "group@example.com"
- }
- ),
- canSign = true
- ),
- chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
- onNavigateToRoute = {},
- onNavigateBack = {},
- )
- }
- }
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupPostsScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupPostsScreen.kt
deleted file mode 100644
index bac3ebd6..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupPostsScreen.kt
+++ /dev/null
@@ -1,352 +0,0 @@
-package press.mantra.compose.ui.composable
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Campaign
-import androidx.compose.material3.Card
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.SnackbarHost
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.material3.TopAppBar
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.lifecycle.viewmodel.compose.viewModel
-import com.vitorpamplona.quartz.nip01Core.core.HexKey
-import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
-import mantra.composeapp.generated.resources.Res
-import mantra.composeapp.generated.resources.a_reply
-import mantra.composeapp.generated.resources.add_post
-import mantra.composeapp.generated.resources.posted_on
-import mantra.composeapp.generated.resources.posts
-import mantra.composeapp.generated.resources.this_group_has_not_posted_anything_yet
-import org.jetbrains.compose.resources.stringResource
-import press.mantra.compose.database.model.ChatRoom
-import press.mantra.compose.database.model.GroupSignedEvent
-import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.extensions.inComparableGroups
-import press.mantra.compose.extensions.toFormattedTimeAndDateString
-import press.mantra.compose.nostr.GroupNostrProfile
-import press.mantra.compose.nostr.GroupPost
-import press.mantra.compose.repository.ChatRepository
-import press.mantra.compose.ui.composable.navigation.routes.AddGroupPostRoute
-import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute
-import press.mantra.compose.ui.composable.navigation.routes.Route
-import press.mantra.compose.ui.composable.widgets.Decorative
-import press.mantra.compose.ui.composable.widgets.EmptyState
-import press.mantra.compose.ui.composable.widgets.ErrorState
-import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
-import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
-import press.mantra.compose.ui.composable.widgets.NavigateBackButton
-import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
-import press.mantra.compose.ui.composable.widgets.buttons.BroadcastButton
-import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar
-import press.mantra.compose.ui.theme.ConformancePreviews
-import press.mantra.compose.ui.theme.MantraTheme
-import press.mantra.compose.ui.theme.readableContent
-import press.mantra.compose.ui.theme.spacing
-import press.mantra.compose.ui.view.model.GroupPostsViewModel
-import press.mantra.compose.ui.view.state.GroupPostsUIState
-import kotlin.time.Instant
-
-/**
- * What the group has said publicly, as itself, newest first.
- *
- * Pushed from its row on the group's screen, which counts the posts and nothing
- * more. This is where they are read: one card per post with a broadcast under
- * it, and -- for a member holding a share of the key -- the way into the
- * composer. A group that has said nothing is told so, and offered the composer
- * from the same place.
- *
- * A post is the one thing the group signs that is meant to be *read* by people
- * who were never in the room, so a member without a share reads it like
- * anybody else and simply cannot propose the next one: proposing is a signature
- * by the group, and `proposeSigningBatch` throws for a device holding no share.
- */
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun GroupPostsScreen(
- activeUserPublicKey: HexKey,
- chatRoomId: String,
- relayHint: String?,
- initialGroupPostsUIState: GroupPostsUIState = GroupPostsUIState.Loading,
- chatRepository: ChatRepository,
- onNavigateToRoute: (Route) -> Unit,
- onNavigateBack: () -> Unit,
-) {
- val groupPostsViewModel: GroupPostsViewModel = viewModel(
- factory = GroupPostsViewModel.factory(
- chatRoomId = chatRoomId,
- initialGroupPostsUIState = initialGroupPostsUIState,
- chatRepository = chatRepository,
- )
- )
-
- LaunchedEffect(true) {
- if (initialGroupPostsUIState == GroupPostsUIState.Loading) {
- groupPostsViewModel.initiate()
- }
- }
-
- Scaffold(
- snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
- topBar = {
- TopAppBar(
- title = {
- Text(
- text = stringResource(Res.string.posts),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- },
- navigationIcon = { NavigateBackButton(onNavigateBack) }
- )
- }
- ) { padding ->
- ScreenStateTransition(groupPostsViewModel.groupPostsUIState) { uiState ->
- when (val state = uiState) {
- is GroupPostsUIState.Error -> {
- // Nothing to retry: the room came from a navigation argument,
- // and reading it again with the same one fails the same way.
- ErrorState(
- modifier = Modifier.padding(padding),
- message = state.message,
- onRetry = null
- )
- }
-
- GroupPostsUIState.Loading -> {
- Column(
- modifier = Modifier.fillMaxWidth().padding(padding)
- .padding(MaterialTheme.spacing.screenMargin),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap)
- ) {
- Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap))
-
- LoadingDataIndicator(fillScreen = false)
- }
- }
-
- is GroupPostsUIState.Loaded -> {
- val add: () -> Unit = {
- onNavigateToRoute.invoke(
- AddGroupPostRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = relayHint
- )
- )
- }
-
- LazyColumn(
- modifier = Modifier.padding(padding).readableContent().fillMaxSize()
- .padding(MaterialTheme.spacing.space250),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
- ) {
- if (state.groupPosts.isEmpty()) {
- item {
- EmptyState(
- message = stringResource(
- Res.string.this_group_has_not_posted_anything_yet
- ),
- action = if (state.canSign) {
- { AddPostButton(onClick = add) }
- } else {
- null
- }
- )
- }
- } else {
- items(
- items = state.groupPosts,
- key = { post -> post.signedEvent.id }
- ) { post ->
- // The card and its broadcast button as one item,
- // so the button stays under the event it sends
- // however the list is arranged.
- Column(
- modifier = Modifier.fillMaxWidth(),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- GroupPostCard(
- post = post,
- publicKey = chatRoomId,
- groupNostrProfile = state.groupNostrProfile
- )
-
- BroadcastButton(
- onClick = {
- onNavigateToRoute.invoke(
- BroadcastGroupSignedEventRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = relayHint,
- signedEventId = post.signedEvent.id
- )
- )
- }
- )
- }
- }
-
- if (state.canSign) {
- item {
- AddPostButton(onClick = add)
- }
- }
- }
- }
- }
- }
- }
- }
-}
-
-/**
- * One thing the group said, as the group signed it.
- *
- * The whole note rather than a preview. A post is short by construction -- kind:1
- * with no long-form arm behind it -- and truncating the one kind of content on this
- * screen that exists to be *read* would mean opening something to see a paragraph.
- *
- * **The byline is the group's**, taken from its profile where it has one and from
- * the room's own id where it has not. It is worth spelling out because a post is
- * the one thing here a reader might otherwise attribute to whichever member
- * proposed it -- and the point of signing it as the group is that they cannot.
- *
- * A reply is marked as one. Nothing in this app writes replies, so the mark is for
- * an event that arrived some other way; drawing one as an announcement would put
- * half a conversation on a group's page with no sign that it was half.
- */
-@Composable
-private fun GroupPostCard(
- post: GroupPost,
- publicKey: HexKey,
- groupNostrProfile: GroupNostrProfile?
-) {
- Card(modifier = Modifier.fillMaxWidth()) {
- Column(
- modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.containerPadding),
- verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap)
- ) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- ProfileAvatar(
- size = MaterialTheme.spacing.space400,
- publicKey = publicKey,
- picture = groupNostrProfile?.picture(),
- name = groupNostrProfile?.name()
- )
-
- Spacer(modifier = Modifier.width(MaterialTheme.spacing.itemGap))
-
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = groupNostrProfile?.name() ?: publicKey.take(16).inComparableGroups(),
- style = MaterialTheme.typography.titleSmall,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
-
- Text(
- text = stringResource(
- Res.string.posted_on,
- post.postedAt.toFormattedTimeAndDateString()
- ),
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
-
- if (!post.isNewThread()) {
- Text(
- text = stringResource(Res.string.a_reply),
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- }
-
- Text(
- text = post.content,
- style = MaterialTheme.typography.bodyMedium
- )
- }
- }
-}
-
-/** The way into `AddGroupPostScreen`. The icon is decorative because the label is beside it. */
-@Composable
-private fun AddPostButton(onClick: () -> Unit) {
- TextButton(onClick = onClick) {
- Icon(Icons.Default.Campaign, contentDescription = Decorative)
-
- Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125))
-
- Text(stringResource(Res.string.add_post))
- }
-}
-
-@ConformancePreviews
-@Composable
-private fun GroupPostsScreenPreview() {
- val chatRoomId = "d".repeat(64)
-
- MantraTheme {
- Surface(modifier = Modifier.fillMaxSize()) {
- GroupPostsScreen(
- activeUserPublicKey = "a".repeat(64),
- chatRoomId = chatRoomId,
- relayHint = null,
- initialGroupPostsUIState = GroupPostsUIState.Loaded(
- localChatRoom = LocalChatRoom(
- chatRoom = ChatRoom(
- id = chatRoomId,
- userPublicKey = "a".repeat(64),
- subject = "Translation room",
- description = null,
- initialGiftWrapPayloadId = "sdfaer",
- mlsGroupState = "state"
- )
- ),
- groupPosts = listOf(
- GroupPost(
- signedEvent = GroupSignedEvent(
- id = "b".repeat(64),
- chatRoomId = chatRoomId,
- publicKey = chatRoomId,
- kind = TextNoteEvent.KIND,
- tags = emptyArray(),
- content = "The first chapter is out. Read it, and tell us where it reads wrong.",
- signature = "f".repeat(128),
- createdAt = Instant.fromEpochSeconds(1_700_000_000)
- )
- )
- ),
- canSign = true
- ),
- chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
- onNavigateToRoute = {},
- onNavigateBack = {},
- )
- }
- }
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupRelaysScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupRelaysScreen.kt
deleted file mode 100644
index febfa89a..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupRelaysScreen.kt
+++ /dev/null
@@ -1,327 +0,0 @@
-package press.mantra.compose.ui.composable
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Dns
-import androidx.compose.material3.Card
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Icon
-import androidx.compose.material3.ListItem
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.SnackbarHost
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.material3.TopAppBar
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.lifecycle.viewmodel.compose.viewModel
-import com.vitorpamplona.quartz.nip01Core.core.HexKey
-import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
-import mantra.composeapp.generated.resources.Res
-import mantra.composeapp.generated.resources.edit_relays
-import mantra.composeapp.generated.resources.no_relays_in_this_list
-import mantra.composeapp.generated.resources.not_set_yet
-import mantra.composeapp.generated.resources.relay_set_blocked
-import mantra.composeapp.generated.resources.relay_set_general
-import mantra.composeapp.generated.resources.relay_set_messages
-import mantra.composeapp.generated.resources.relay_set_search
-import mantra.composeapp.generated.resources.relays
-import org.jetbrains.compose.resources.stringResource
-import press.mantra.compose.database.model.ChatRoom
-import press.mantra.compose.database.model.GroupSignedEvent
-import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.nostr.GroupRelay
-import press.mantra.compose.nostr.GroupRelayList
-import press.mantra.compose.nostr.GroupRelaySet
-import press.mantra.compose.repository.ChatRepository
-import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute
-import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute
-import press.mantra.compose.ui.composable.navigation.routes.Route
-import press.mantra.compose.ui.composable.widgets.Decorative
-import press.mantra.compose.ui.composable.widgets.ErrorState
-import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
-import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
-import press.mantra.compose.ui.composable.widgets.NavigateBackButton
-import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
-import press.mantra.compose.ui.composable.widgets.buttons.BroadcastButton
-import press.mantra.compose.ui.theme.ConformancePreviews
-import press.mantra.compose.ui.theme.MantraTheme
-import press.mantra.compose.ui.theme.readableContent
-import press.mantra.compose.ui.theme.spacing
-import press.mantra.compose.ui.view.model.GroupRelaysViewModel
-import press.mantra.compose.ui.view.state.GroupRelaysUIState
-import kotlin.time.Instant
-
-/**
- * Where the group has said it lives on nostr, one line per relay list.
- *
- * Pushed from its row on the group's screen, which says how many of the four
- * lists the group has agreed and nothing more. This is where they are read,
- * with a broadcast under each one the group has signed and -- for a member
- * holding a share of the key -- the way into the editor.
- *
- * There is no empty state to draw: every set gets a row whatever the group has
- * said, and the row is where "never agreed" and "agreed empty" are told apart.
- * See [GroupRelayListsCard].
- */
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun GroupRelaysScreen(
- activeUserPublicKey: HexKey,
- chatRoomId: String,
- relayHint: String?,
- initialGroupRelaysUIState: GroupRelaysUIState = GroupRelaysUIState.Loading,
- chatRepository: ChatRepository,
- onNavigateToRoute: (Route) -> Unit,
- onNavigateBack: () -> Unit,
-) {
- val groupRelaysViewModel: GroupRelaysViewModel = viewModel(
- factory = GroupRelaysViewModel.factory(
- chatRoomId = chatRoomId,
- initialGroupRelaysUIState = initialGroupRelaysUIState,
- chatRepository = chatRepository,
- )
- )
-
- LaunchedEffect(true) {
- if (initialGroupRelaysUIState == GroupRelaysUIState.Loading) {
- groupRelaysViewModel.initiate()
- }
- }
-
- Scaffold(
- snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
- topBar = {
- TopAppBar(
- title = {
- Text(
- text = stringResource(Res.string.relays),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- },
- navigationIcon = { NavigateBackButton(onNavigateBack) }
- )
- }
- ) { padding ->
- ScreenStateTransition(groupRelaysViewModel.groupRelaysUIState) { uiState ->
- when (val state = uiState) {
- is GroupRelaysUIState.Error -> {
- // Nothing to retry: the room came from a navigation argument,
- // and reading it again with the same one fails the same way.
- ErrorState(
- modifier = Modifier.padding(padding),
- message = state.message,
- onRetry = null
- )
- }
-
- GroupRelaysUIState.Loading -> {
- Column(
- modifier = Modifier.fillMaxWidth().padding(padding)
- .padding(MaterialTheme.spacing.screenMargin),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap)
- ) {
- Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap))
-
- LoadingDataIndicator(fillScreen = false)
- }
- }
-
- is GroupRelaysUIState.Loaded -> {
- LazyColumn(
- modifier = Modifier.padding(padding).readableContent().fillMaxSize()
- .padding(MaterialTheme.spacing.space250),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
- ) {
- item {
- GroupRelayListsCard(
- relayLists = state.groupRelayLists,
- onBroadcast = { signedEvent ->
- onNavigateToRoute.invoke(
- BroadcastGroupSignedEventRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = relayHint,
- signedEventId = signedEvent.id
- )
- )
- }
- )
- }
-
- // The lists are worth reading either way; proposing them
- // is a signature by the group, and `proposeSigningBatch`
- // throws for a device holding no share of the key. Hidden
- // rather than disabled, the way every gated way in on the
- // group's screen is.
- if (state.canSign) {
- item {
- TextButton(
- onClick = {
- onNavigateToRoute.invoke(
- EditGroupRelaysRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = relayHint
- )
- )
- }
- ) {
- Icon(Icons.Default.Dns, contentDescription = Decorative)
-
- Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125))
-
- Text(stringResource(Res.string.edit_relays))
- }
- }
- }
- }
- }
- }
- }
- }
-}
-
-/**
- * Where the group has said it lives on nostr, one line per relay list.
- *
- * One card holding four rows rather than four cards: they are read together -- the
- * question is whether the group is reachable, not what any single list says -- and
- * four cards of one line each is four dividers to scroll past for the same four
- * facts.
- *
- * **A list the group has never agreed reads "not set yet", and an agreed empty one
- * reads "no relays".** Those are different things: one is a group that has not got
- * round to it, and the other is a group that decided. Collapsing them would hide a
- * deliberate withdrawal behind an oversight.
- */
-@Composable
-private fun GroupRelayListsCard(
- relayLists: List,
- onBroadcast: (GroupSignedEvent) -> Unit
-) {
- Card(modifier = Modifier.fillMaxWidth()) {
- relayLists.forEach { list ->
- ListItem(
- leadingContent = {
- Icon(Icons.Default.Dns, contentDescription = Decorative)
- },
- headlineContent = {
- Text(text = stringResource(list.set.summaryLabel()))
- },
- supportingContent = {
- Text(
- text = when {
- !list.isAgreed -> stringResource(Res.string.not_set_yet)
- list.relays.isEmpty() ->
- stringResource(Res.string.no_relays_in_this_list)
- // Hosts rather than URLs: every one of them starts
- // `wss://`, so the scheme is the part that never
- // distinguishes two entries.
- else -> list.relays.joinToString { it.displayUrl() }
- },
- maxLines = 2,
- overflow = TextOverflow.Ellipsis
- )
- }
- )
-
- // Under the row it sends, and only where there is something signed
- // to send: a list the group has never agreed is not an event. Inside
- // the card because the four rows share one, so "under the event" has
- // to mean under the row -- and at the end, where M3 puts a card's
- // actions. An agreed empty list gets one too: a withdrawal is a
- // statement, and relays still holding the old list need to hear it.
- list.signedEvent?.let { signedEvent ->
- Box(
- modifier = Modifier.fillMaxWidth()
- .padding(horizontal = MaterialTheme.spacing.compactPadding),
- contentAlignment = Alignment.CenterEnd
- ) {
- BroadcastButton(onClick = { onBroadcast(signedEvent) })
- }
- }
- }
- }
-}
-
-/** The name of a relay list, for the row that summarises it. */
-private fun GroupRelaySet.summaryLabel() = when (this) {
- GroupRelaySet.General -> Res.string.relay_set_general
- GroupRelaySet.Messages -> Res.string.relay_set_messages
- GroupRelaySet.Search -> Res.string.relay_set_search
- GroupRelaySet.Blocked -> Res.string.relay_set_blocked
-}
-
-@ConformancePreviews
-@Composable
-private fun GroupRelaysScreenPreview() {
- val chatRoomId = "d".repeat(64)
-
- MantraTheme {
- Surface(modifier = Modifier.fillMaxSize()) {
- GroupRelaysScreen(
- activeUserPublicKey = "a".repeat(64),
- chatRoomId = chatRoomId,
- relayHint = null,
- initialGroupRelaysUIState = GroupRelaysUIState.Loaded(
- localChatRoom = LocalChatRoom(
- chatRoom = ChatRoom(
- id = chatRoomId,
- userPublicKey = "a".repeat(64),
- subject = "Translation room",
- description = null,
- initialGiftWrapPayloadId = "sdfaer",
- mlsGroupState = "state"
- )
- ),
- // One agreed list and three the group has not got round to,
- // so the two things a row can say are both on the screen.
- groupRelayLists = GroupRelaySet.entries.map { set ->
- if (set == GroupRelaySet.General) {
- GroupRelayList(
- set = set,
- signedEvent = GroupSignedEvent(
- id = "e".repeat(64),
- chatRoomId = chatRoomId,
- publicKey = chatRoomId,
- kind = set.kind,
- tags = emptyArray(),
- content = "",
- signature = "f".repeat(128),
- createdAt = Instant.fromEpochSeconds(1_700_000_000)
- ),
- relays = listOf(
- GroupRelay(RelayUrlNormalizer.normalize("wss://relay.one.example"))
- )
- )
- } else {
- GroupRelayList(set = set, signedEvent = null, relays = emptyList())
- }
- },
- canSign = true
- ),
- chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
- onNavigateToRoute = {},
- onNavigateBack = {},
- )
- }
- }
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposeGroupEventScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposeGroupEventScreen.kt
deleted file mode 100644
index 6b4f4a86..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposeGroupEventScreen.kt
+++ /dev/null
@@ -1,469 +0,0 @@
-package press.mantra.compose.ui.composable
-
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.imePadding
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.text.input.TextFieldLineLimits
-import androidx.compose.foundation.text.input.rememberTextFieldState
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Draw
-import androidx.compose.material3.BottomAppBar
-import androidx.compose.material3.BottomAppBarDefaults
-import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.Card
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
-import androidx.compose.material3.ExtendedFloatingActionButton
-import androidx.compose.material3.FloatingActionButtonDefaults
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.OutlinedTextFieldDefaults
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.SnackbarHost
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBar
-import androidx.compose.material3.contentColorFor
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.semantics.disabled
-import androidx.compose.ui.semantics.semantics
-import androidx.compose.ui.text.TextStyle
-import androidx.compose.ui.text.font.FontFamily
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.lifecycle.viewmodel.compose.viewModel
-import press.mantra.compose.database.model.ChatRoom
-import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.nostr.GroupEventProposal
-import press.mantra.compose.repository.ChatRepository
-import press.mantra.compose.repository.FrostSigningRepository
-import press.mantra.compose.text.ProposedEvent
-import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
-import press.mantra.compose.ui.composable.navigation.routes.Route
-import press.mantra.compose.ui.composable.widgets.ErrorState
-import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
-import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
-import press.mantra.compose.ui.composable.widgets.NavigateBackButton
-import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
-import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar
-import press.mantra.compose.ui.composable.widgets.rememberNotifier
-import press.mantra.compose.ui.theme.ConformancePreviews
-import press.mantra.compose.ui.theme.MantraTheme
-import press.mantra.compose.ui.theme.readableContent
-import press.mantra.compose.ui.theme.spacing
-import press.mantra.compose.ui.view.model.ProposeGroupEventViewModel
-import press.mantra.compose.ui.view.state.ProposeGroupEventUIState
-import com.vitorpamplona.quartz.nip01Core.core.HexKey
-import mantra.composeapp.generated.resources.Res
-import mantra.composeapp.generated.resources.a_post_needs_something_to_say
-import mantra.composeapp.generated.resources.an_event_is_a_json_object
-import mantra.composeapp.generated.resources.an_event_needs_a_kind
-import mantra.composeapp.generated.resources.content_must_be_text
-import mantra.composeapp.generated.resources.could_not_ask_the_group_to_sign_this_event
-import mantra.composeapp.generated.resources.eg_kind_1_tags_content
-import mantra.composeapp.generated.resources.kind_n
-import mantra.composeapp.generated.resources.kind_n_has_nowhere_to_go_on_this_screen
-import mantra.composeapp.generated.resources.paste_an_unsigned_event_for_the_group_to_sign
-import mantra.composeapp.generated.resources.propose_event
-import mantra.composeapp.generated.resources.tags_must_be_a_list_of_lists_of_strings
-import mantra.composeapp.generated.resources.that_is_not_json
-import mantra.composeapp.generated.resources.the_content_is_not_a_profile
-import mantra.composeapp.generated.resources.the_group_signs_an_event_so_it_takes_a_quorum
-import mantra.composeapp.generated.resources.this_group_has_no_shared_key_to_sign_an_event
-import mantra.composeapp.generated.resources.this_schema_cannot_be_signed_as_it_is
-import mantra.composeapp.generated.resources.unsigned_event
-import mantra.composeapp.generated.resources.what_the_group_would_sign
-import org.jetbrains.compose.resources.stringResource
-
-/**
- * An unsigned event, pasted, for the group to sign.
- *
- * The typed editors each build one kind of statement; this takes any of the
- * kinds the group's screen can show, as JSON, the way another tool prints one.
- * Above the paste is what the group would be signing, read back the way the
- * signing screen will describe it, so a member sees "Curated schema · bitcoin.mov"
- * rather than a wall of brackets before deciding -- and sees why the group will
- * not be asked when the paste is something it could never show.
- *
- * The byline is the group's, as on the post composer and for the same reason:
- * whatever is pasted goes out in the group's name, and a paste that names some
- * other `pubkey` goes out in the group's name anyway. The explanatory line says
- * so, because a paste with a `pubkey` in it is the one case where a member could
- * reasonably expect otherwise.
- */
-@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
-@Composable
-fun ProposeGroupEventScreen(
- activeUserPublicKey: HexKey,
- chatRoomId: String,
- relayHint: String?,
- initialProposeGroupEventUIState: ProposeGroupEventUIState = ProposeGroupEventUIState.Loading,
- chatRepository: ChatRepository,
- frostSigningRepository: FrostSigningRepository,
- onNavigateToRouteAndPopUpInclusive: (Route) -> Unit,
- onNavigateBack: () -> Unit,
-) {
- val proposeGroupEventViewModel: ProposeGroupEventViewModel = viewModel(
- factory = ProposeGroupEventViewModel.factory(
- chatRoomId = chatRoomId,
- relayHint = relayHint,
- initialProposeGroupEventUIState = initialProposeGroupEventUIState,
- activeUserPublicKey = activeUserPublicKey,
- chatRepository = chatRepository,
- frostSigningRepository = frostSigningRepository
- )
- )
-
- // Read out here rather than in the click handler: both are composable and an
- // onClick lambda is not. The scope is the caller's so a message survives this
- // screen being replaced by the signing session.
- val notify = rememberNotifier(rememberCoroutineScope())
- val couldNotPropose = stringResource(Res.string.could_not_ask_the_group_to_sign_this_event)
-
- ScreenStateTransition(proposeGroupEventViewModel.proposeGroupEventUIState) { uiState ->
- when (val proposeGroupEventUIState = uiState) {
- is ProposeGroupEventUIState.Error -> {
- // Nothing to retry: the room came from a navigation argument, and
- // reading it again with the same one fails the same way.
- ErrorState(message = proposeGroupEventUIState.message, onRetry = null)
- }
-
- is ProposeGroupEventUIState.Loaded -> {
- val eventFieldState = rememberTextFieldState()
- val canSign = proposeGroupEventUIState.canSign
-
- // Read on every change. A paste is a few kilobytes at most and the
- // reading is one JSON parse, so this is cheaper than the debounce
- // that would hide the answer for a beat after the paste.
- val json = eventFieldState.text.toString()
- val reading = remember(json) {
- json.takeIf { it.isNotBlank() }?.let(GroupEventProposal::read)
- }
-
- // M3 gives a FAB no `enabled`, so borrow the disabled colours every
- // other button in the app uses rather than inventing a shade here.
- val buttonColors = ButtonDefaults.buttonColors()
-
- // imePadding: this screen is a text field the size of the screen,
- // and without it the software keyboard covers what is being pasted.
- Scaffold(
- snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
- modifier = Modifier.imePadding(),
- topBar = {
- TopAppBar(
- title = { Text(text = stringResource(Res.string.propose_event)) },
- navigationIcon = { NavigateBackButton(onNavigateBack) },
- )
- },
- bottomBar = {
- BottomAppBar(
- actions = {},
- floatingActionButton = {
- ExtendedFloatingActionButton(
- modifier = if (canSign) {
- Modifier
- } else {
- // Looking unavailable is not being unavailable.
- Modifier.semantics { disabled() }
- },
- containerColor = if (canSign) {
- FloatingActionButtonDefaults.containerColor
- } else {
- buttonColors.disabledContainerColor
- },
- contentColor = if (canSign) {
- contentColorFor(FloatingActionButtonDefaults.containerColor)
- } else {
- buttonColors.disabledContentColor
- },
- onClick = {
- if (!canSign) return@ExtendedFloatingActionButton
-
- proposeGroupEventViewModel.proposeEvent(
- localChatRoom = proposeGroupEventUIState.localChatRoom,
- json = eventFieldState.text.toString(),
- onSuccess = { sessionId ->
- // Onto the session rather than back
- // to the group. Nothing has been
- // signed yet -- the event lands when
- // a quorum signs -- so landing on a
- // screen that does not show it would
- // read as a failure.
- onNavigateToRouteAndPopUpInclusive.invoke(
- FrostSigningRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- sessionId = sessionId
- )
- )
- },
- // The reason is already under the paste;
- // pressing the button anyway is answered
- // rather than ignored, and the paste
- // stays. Same on a failure to propose.
- onRefused = {},
- onFailure = { notify(couldNotPropose) }
- )
- }
- ) {
- Icon(Icons.Default.Draw, contentDescription = "Propose event")
- Text(stringResource(Res.string.propose_event))
- }
- }
- )
- }
- ) { innerPadding ->
- Column(
- modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize(),
- verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- // Whose name this goes out in, said before anything is
- // pasted. It is the group's, whatever the paste says.
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically
- ) {
- ProfileAvatar(
- size = MaterialTheme.spacing.space500,
- publicKey = chatRoomId,
- picture = proposeGroupEventUIState.groupNostrProfile?.picture(),
- name = proposeGroupEventUIState.groupNostrProfile?.name()
- )
-
- Spacer(modifier = Modifier.width(MaterialTheme.spacing.itemGap))
-
- Text(
- text = proposeGroupEventUIState.groupNostrProfile?.name()
- ?: proposeGroupEventUIState.localChatRoom.chatRoom.subject
- ?: chatRoomId.take(16),
- style = MaterialTheme.typography.titleSmall,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- }
-
- Text(
- text = stringResource(
- Res.string.paste_an_unsigned_event_for_the_group_to_sign
- ),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- textAlign = TextAlign.Center
- )
-
- Text(
- text = stringResource(
- Res.string.the_group_signs_an_event_so_it_takes_a_quorum
- ),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- textAlign = TextAlign.Center
- )
-
- if (!canSign) {
- Text(
- text = stringResource(
- Res.string.this_group_has_no_shared_key_to_sign_an_event
- ),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.error,
- textAlign = TextAlign.Center
- )
- }
-
- when (reading) {
- null -> Unit
- is GroupEventProposal.Ready -> ProposalPreview(reading)
- is GroupEventProposal.Refused -> RefusalNotice(reading)
- }
-
- OutlinedTextField(
- modifier = Modifier.fillMaxWidth().weight(1f)
- .background(BottomAppBarDefaults.containerColor),
- state = eventFieldState,
- // The paste grows into the screen rather than scrolling
- // inside three lines, and is set in a monospace face:
- // it is JSON, and a bracket that does not line up is
- // the kind of thing a member is looking for in it.
- lineLimits = TextFieldLineLimits.MultiLine(),
- textStyle = TextStyle(fontFamily = FontFamily.Monospace),
- colors = OutlinedTextFieldDefaults.colors(
- focusedBorderColor = Color.Transparent,
- unfocusedBorderColor = Color.Transparent,
- disabledBorderColor = Color.Transparent
- ),
- label = { Text(stringResource(Res.string.unsigned_event)) },
- placeholder = {
- Text(
- text = stringResource(Res.string.eg_kind_1_tags_content),
- fontFamily = FontFamily.Monospace
- )
- },
- isError = reading is GroupEventProposal.Refused
- )
- }
- }
- }
-
- ProposeGroupEventUIState.Loading -> {
- Column(
- modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.screenMargin),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap)
- ) {
- Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap))
-
- Text(
- text = stringResource(Res.string.propose_event),
- style = MaterialTheme.typography.bodyLarge,
- textAlign = TextAlign.Center
- )
-
- LoadingDataIndicator(fillScreen = false)
- }
- }
- }
- }
-
- LaunchedEffect(true) {
- if (initialProposeGroupEventUIState == ProposeGroupEventUIState.Loading) {
- proposeGroupEventViewModel.initiateProposeGroupEvent()
- }
- }
-}
-
-/**
- * What the group would sign, said the way the signing screen will say it.
- *
- * `ProposedEvent.summarize` rather than a description of this screen's own, so
- * that what a member reads here before proposing is word for word what every
- * other member reads before signing. Two descriptions of one event would drift.
- */
-@Composable
-private fun ProposalPreview(ready: GroupEventProposal.Ready) {
- val summary = ProposedEvent.summarize(ready.preview())
-
- Card(modifier = Modifier.fillMaxWidth()) {
- Column(
- modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.containerPadding),
- verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)
- ) {
- Text(
- text = stringResource(Res.string.what_the_group_would_sign),
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
-
- Text(
- text = stringResource(Res.string.kind_n, ready.kind.toString()) + " · " + summary.label,
- style = MaterialTheme.typography.titleSmall
- )
-
- // Capped, because a kind:1's detail is the whole post and the paste
- // under this card is the thing the screen is for.
- Text(
- text = summary.detail,
- style = MaterialTheme.typography.bodyMedium,
- maxLines = 6,
- overflow = TextOverflow.Ellipsis
- )
- }
- }
-}
-
-/**
- * Why the group will not be asked, under the paste it is about.
- *
- * Named rather than silent: a button that does nothing for a paste it refuses is
- * indistinguishable from a missed tap. A schema's reasons are the same sentences
- * the schema editor uses, since they are the same checks.
- */
-@Composable
-private fun RefusalNotice(refused: GroupEventProposal.Refused) {
- Column(
- modifier = Modifier.fillMaxWidth(),
- verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)
- ) {
- Text(
- text = when (refused) {
- GroupEventProposal.Refused.NotJson -> stringResource(Res.string.that_is_not_json)
- GroupEventProposal.Refused.NotAnObject -> stringResource(Res.string.an_event_is_a_json_object)
- GroupEventProposal.Refused.NoKind -> stringResource(Res.string.an_event_needs_a_kind)
- is GroupEventProposal.Refused.KindNotAccepted -> stringResource(
- Res.string.kind_n_has_nowhere_to_go_on_this_screen,
- refused.kind.toString(),
- GroupEventProposal.ACCEPTED_KINDS.sorted().joinToString()
- )
- GroupEventProposal.Refused.TagsMalformed ->
- stringResource(Res.string.tags_must_be_a_list_of_lists_of_strings)
- GroupEventProposal.Refused.ContentNotText -> stringResource(Res.string.content_must_be_text)
- GroupEventProposal.Refused.ProfileUnreadable ->
- stringResource(Res.string.the_content_is_not_a_profile)
- GroupEventProposal.Refused.PostBlank -> stringResource(Res.string.a_post_needs_something_to_say)
- is GroupEventProposal.Refused.SchemaUnusable ->
- stringResource(Res.string.this_schema_cannot_be_signed_as_it_is)
- },
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.error
- )
-
- if (refused is GroupEventProposal.Refused.SchemaUnusable) {
- refused.problems.forEach { problem ->
- Text(
- text = problem.message(),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.error
- )
- }
- }
- }
-}
-
-@ConformancePreviews
-@Composable
-private fun ProposeGroupEventScreenPreview() {
- MantraTheme {
- Surface(modifier = Modifier.fillMaxSize()) {
- ProposeGroupEventScreen(
- activeUserPublicKey = "",
- chatRoomId = "publicKey",
- relayHint = null,
- initialProposeGroupEventUIState = ProposeGroupEventUIState.Loaded(
- localChatRoom = LocalChatRoom(
- chatRoom = ChatRoom(
- id = "publicKey",
- userPublicKey = "",
- subject = "Translation room",
- description = "A group translating hard books.",
- initialGiftWrapPayloadId = "sdfaer",
- mlsGroupState = "state"
- ),
- ),
- // A group that can sign, so the screen renders in the state a
- // member actually meets it in rather than greyed out.
- canSign = true
- ),
- chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
- frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
- onNavigateToRouteAndPopUpInclusive = {},
- onNavigateBack = {},
- )
- }
- }
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt
index c70940d1..5b6a684f 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt
@@ -40,19 +40,7 @@ import press.mantra.compose.ui.composable.DkgJoinApprovalScreen
import press.mantra.compose.ui.composable.DkgRitualScreen
import press.mantra.compose.ui.composable.DkgRound1ApprovalScreen
import press.mantra.compose.ui.composable.DkgRound2ApprovalScreen
-import press.mantra.compose.ui.composable.EditGroupNostrProfileScreen
-import press.mantra.compose.ui.composable.AddGroupPostScreen
-import press.mantra.compose.ui.composable.AcceptCuratedSuggestionScreen
-import press.mantra.compose.ui.composable.EditGroupCuratedSchemaScreen
import press.mantra.compose.ui.composable.BroadcastGroupSignedEventScreen
-import press.mantra.compose.ui.composable.CuratedSuggestionListScreen
-import press.mantra.compose.ui.composable.ProposeGroupEventScreen
-import press.mantra.compose.ui.composable.EditGroupRelaysScreen
-import press.mantra.compose.ui.composable.GroupNostrProfileScreen
-import press.mantra.compose.ui.composable.GroupRelaysScreen
-import press.mantra.compose.ui.composable.GroupPostsScreen
-import press.mantra.compose.ui.composable.GroupCuratedSchemasScreen
-import press.mantra.compose.ui.composable.GroupCuratedEntriesScreen
import press.mantra.compose.ui.composable.HomeScreen
import press.mantra.compose.ui.composable.ImplementationPendingScreen
import press.mantra.compose.ui.composable.KeyPackageManagementScreen
@@ -139,19 +127,7 @@ import press.mantra.compose.database.repository.DatabaseMantraRepository
import press.mantra.compose.ui.composable.AddArtifactScreen
import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute
import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute
-import press.mantra.compose.ui.composable.navigation.routes.EditGroupNostrProfileRoute
-import press.mantra.compose.ui.composable.navigation.routes.AddGroupPostRoute
-import press.mantra.compose.ui.composable.navigation.routes.AcceptCuratedSuggestionRoute
-import press.mantra.compose.ui.composable.navigation.routes.EditGroupCuratedSchemaRoute
import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute
-import press.mantra.compose.ui.composable.navigation.routes.CuratedSuggestionListRoute
-import press.mantra.compose.ui.composable.navigation.routes.ProposeGroupEventRoute
-import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute
-import press.mantra.compose.ui.composable.navigation.routes.GroupNostrProfileRoute
-import press.mantra.compose.ui.composable.navigation.routes.GroupRelaysRoute
-import press.mantra.compose.ui.composable.navigation.routes.GroupPostsRoute
-import press.mantra.compose.ui.composable.navigation.routes.GroupCuratedSchemasRoute
-import press.mantra.compose.ui.composable.navigation.routes.GroupCuratedEntriesRoute
import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute
import press.mantra.compose.ui.composable.navigation.routes.AddChapterRoute
@@ -1116,223 +1092,6 @@ fun MantraNavHost(
}
)
}
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- GroupNostrProfileScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- chatRoomId = route.chatRoomId,
- relayHint = route.relayHint,
- chatRepository = databaseChatRepository,
- onNavigateToRoute = { actionRoute ->
- navController.navigate(route = actionRoute)
- },
- onNavigateBack = {
- navController.popBackStack()
- }
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- GroupRelaysScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- chatRoomId = route.chatRoomId,
- relayHint = route.relayHint,
- chatRepository = databaseChatRepository,
- onNavigateToRoute = { actionRoute ->
- navController.navigate(route = actionRoute)
- },
- onNavigateBack = {
- navController.popBackStack()
- }
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- GroupPostsScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- chatRoomId = route.chatRoomId,
- relayHint = route.relayHint,
- chatRepository = databaseChatRepository,
- onNavigateToRoute = { actionRoute ->
- navController.navigate(route = actionRoute)
- },
- onNavigateBack = {
- navController.popBackStack()
- }
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- GroupCuratedSchemasScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- chatRoomId = route.chatRoomId,
- relayHint = route.relayHint,
- chatRepository = databaseChatRepository,
- onNavigateToRoute = { actionRoute ->
- navController.navigate(route = actionRoute)
- },
- onNavigateBack = {
- navController.popBackStack()
- }
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- GroupCuratedEntriesScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- chatRoomId = route.chatRoomId,
- relayHint = route.relayHint,
- chatRepository = databaseChatRepository,
- onNavigateToRoute = { actionRoute ->
- navController.navigate(route = actionRoute)
- },
- onNavigateBack = {
- navController.popBackStack()
- }
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- EditGroupNostrProfileScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- chatRoomId = route.chatRoomId,
- relayHint = route.relayHint,
- chatRepository = databaseChatRepository,
- frostSigningRepository = databaseFrostSigningRepository,
- onNavigateToRouteAndPopUpInclusive = { signingRoute ->
- // Replace the form so back returns to the group rather than
- // to an edit whose proposal has already gone out.
- navController.navigate(route = signingRoute) {
- popUpTo {
- inclusive = true
- }
- }
- },
- onNavigateBack = {
- navController.popBackStack()
- }
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- EditGroupRelaysScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- chatRoomId = route.chatRoomId,
- relayHint = route.relayHint,
- chatRepository = databaseChatRepository,
- frostSigningRepository = databaseFrostSigningRepository,
- onNavigateToRouteAndPopUpInclusive = { signingRoute ->
- // Replace the editor so back returns to the group rather
- // than to lists whose proposal has already gone out.
- navController.navigate(route = signingRoute) {
- popUpTo {
- inclusive = true
- }
- }
- },
- onNavigateBack = {
- navController.popBackStack()
- }
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- AddGroupPostScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- chatRoomId = route.chatRoomId,
- relayHint = route.relayHint,
- chatRepository = databaseChatRepository,
- frostSigningRepository = databaseFrostSigningRepository,
- onNavigateToRouteAndPopUpInclusive = { signingRoute ->
- // Replace the composer so back returns to the group rather
- // than to a draft whose proposal has already gone out.
- navController.navigate(route = signingRoute) {
- popUpTo {
- inclusive = true
- }
- }
- },
- onNavigateBack = {
- navController.popBackStack()
- }
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- EditGroupCuratedSchemaScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- chatRoomId = route.chatRoomId,
- relayHint = route.relayHint,
- identifier = route.identifier,
- chatRepository = databaseChatRepository,
- frostSigningRepository = databaseFrostSigningRepository,
- onNavigateToRouteAndPopUpInclusive = { signingRoute ->
- // Replace the editor so back returns to the group rather
- // than to a schema whose proposal has already gone out.
- navController.navigate(route = signingRoute) {
- popUpTo {
- inclusive = true
- }
- }
- },
- onNavigateBack = {
- navController.popBackStack()
- }
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- CuratedSuggestionListScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- chatRoomId = route.chatRoomId,
- relayHint = route.relayHint,
- identifier = route.identifier,
- nostrRepository = databaseNostrRepository,
- chatRepository = databaseChatRepository,
- onNavigateBack = {
- navController.popBackStack()
- },
- onNavigateToRoute = { acceptRoute ->
- navController.navigate(route = acceptRoute)
- }
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- AcceptCuratedSuggestionScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- chatRoomId = route.chatRoomId,
- relayHint = route.relayHint,
- identifier = route.identifier,
- suggestionId = route.suggestionId,
- nostrRepository = databaseNostrRepository,
- chatRepository = databaseChatRepository,
- frostSigningRepository = databaseFrostSigningRepository,
- onNavigateBack = {
- navController.popBackStack()
- },
- onNavigateToRouteAndPopUpInclusive = { signingRoute ->
- // Replace the form so back returns to the queue rather
- // than to an entry whose proposal has already gone out.
- navController.navigate(route = signingRoute) {
- popUpTo {
- inclusive = true
- }
- }
- }
- )
- }
composable { backStackEntry ->
val route = backStackEntry.toRoute()
@@ -1352,29 +1111,6 @@ fun MantraNavHost(
}
)
}
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- ProposeGroupEventScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- chatRoomId = route.chatRoomId,
- relayHint = route.relayHint,
- chatRepository = databaseChatRepository,
- frostSigningRepository = databaseFrostSigningRepository,
- onNavigateToRouteAndPopUpInclusive = { signingRoute ->
- // Replace the paste so back returns to the group rather
- // than to an event whose proposal has already gone out.
- navController.navigate(route = signingRoute) {
- popUpTo {
- inclusive = true
- }
- }
- },
- onNavigateBack = {
- navController.popBackStack()
- }
- )
- }
composable { backStackEntry ->
val route = backStackEntry.toRoute()
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AcceptCuratedSuggestionRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AcceptCuratedSuggestionRoute.kt
deleted file mode 100644
index 2ade6774..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AcceptCuratedSuggestionRoute.kt
+++ /dev/null
@@ -1,22 +0,0 @@
-package press.mantra.compose.ui.composable.navigation.routes
-
-import kotlinx.serialization.Serializable
-
-/**
- * The form for taking one suggestion into a list the group curates: the entry
- * as it was suggested, open for the group's edits, and a button that asks the
- * group to sign it as the list's own.
- *
- * [identifier] is the schema's `d`, for the reason `CuratedSuggestionListRoute`
- * gives; [suggestionId] is the suggestion's event id rather than its
- * coordinate, because the canonical entry pins the exact version the group
- * read -- the NIP's `e` tag -- and the id is what names a version.
- */
-@Serializable
-data class AcceptCuratedSuggestionRoute(
- val activeUserPublicKey: String,
- val chatRoomId: String, // TODO: have this as a publicKey
- val relayHint: String?,
- val identifier: String,
- val suggestionId: String
-): Route()
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddGroupPostRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddGroupPostRoute.kt
deleted file mode 100644
index 7400f78f..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddGroupPostRoute.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-package press.mantra.compose.ui.composable.navigation.routes
-
-import kotlinx.serialization.Serializable
-
-@Serializable
-data class AddGroupPostRoute(
- val activeUserPublicKey: String,
- val chatRoomId: String, // TODO: have this as a publicKey
- val relayHint: String?
-): Route()
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/CuratedSuggestionListRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/CuratedSuggestionListRoute.kt
deleted file mode 100644
index 9659aecb..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/CuratedSuggestionListRoute.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-package press.mantra.compose.ui.composable.navigation.routes
-
-import kotlinx.serialization.Serializable
-
-/**
- * The queue of one list the group curates: everything anyone has suggested to
- * the schema at [identifier], read off the relays the schema names.
- *
- * Named by the schema's `d` for the reason `EditGroupCuratedSchemaRoute` is:
- * the coordinate is what every suggestion replies to, and the group re-signing
- * the schema changes the event without changing the queue.
- */
-@Serializable
-data class CuratedSuggestionListRoute(
- val activeUserPublicKey: String,
- val chatRoomId: String, // TODO: have this as a publicKey
- val relayHint: String?,
- val identifier: String
-): Route()
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupCuratedSchemaRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupCuratedSchemaRoute.kt
deleted file mode 100644
index a56bf15b..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupCuratedSchemaRoute.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-package press.mantra.compose.ui.composable.navigation.routes
-
-import kotlinx.serialization.Serializable
-
-/**
- * The editor for one of a group's curated schemas.
- *
- * [identifier] is the schema's `d`, and null opens the editor on a new one. It
- * is the identifier rather than the event id because kind 31889 is addressable:
- * an edit replaces the event under the same `d`, and the editor's job is to
- * propose that replacement, so the coordinate is what names the thing being
- * edited and the event is only its current version.
- */
-@Serializable
-data class EditGroupCuratedSchemaRoute(
- val activeUserPublicKey: String,
- val chatRoomId: String, // TODO: have this as a publicKey
- val relayHint: String?,
- val identifier: String?
-): Route()
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupNostrProfileRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupNostrProfileRoute.kt
deleted file mode 100644
index 426ce90c..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupNostrProfileRoute.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-package press.mantra.compose.ui.composable.navigation.routes
-
-import kotlinx.serialization.Serializable
-
-@Serializable
-data class EditGroupNostrProfileRoute(
- val activeUserPublicKey: String,
- val chatRoomId: String, // TODO: have this as a publicKey
- val relayHint: String?
-): Route()
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupRelaysRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupRelaysRoute.kt
deleted file mode 100644
index 5c0fe7f0..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupRelaysRoute.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-package press.mantra.compose.ui.composable.navigation.routes
-
-import kotlinx.serialization.Serializable
-
-@Serializable
-data class EditGroupRelaysRoute(
- val activeUserPublicKey: String,
- val chatRoomId: String, // TODO: have this as a publicKey
- val relayHint: String?
-): Route()
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupCuratedEntriesRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupCuratedEntriesRoute.kt
deleted file mode 100644
index e70359a3..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupCuratedEntriesRoute.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-package press.mantra.compose.ui.composable.navigation.routes
-
-import kotlinx.serialization.Serializable
-
-/**
- * The entries the group has accepted into the lists it curates, newest first,
- * with a broadcast under each. Nothing is added from here: an entry is accepted
- * from the queue it was suggested to, which is reached from its schema's card
- * on the [GroupCuratedSchemasRoute] screen.
- */
-@Serializable
-data class GroupCuratedEntriesRoute(
- val activeUserPublicKey: String,
- val chatRoomId: String, // TODO: have this as a publicKey
- val relayHint: String?
-): Route()
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupCuratedSchemasRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupCuratedSchemasRoute.kt
deleted file mode 100644
index ab698bf9..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupCuratedSchemasRoute.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-package press.mantra.compose.ui.composable.navigation.routes
-
-import kotlinx.serialization.Serializable
-
-/**
- * The lists the group curates, as the schemas it signed for them, one card per
- * list with its queue and its broadcast under it. Each card is the way into
- * that schema's editor for a member holding a share of the key, and the screen
- * ends with the way to add one.
- */
-@Serializable
-data class GroupCuratedSchemasRoute(
- val activeUserPublicKey: String,
- val chatRoomId: String, // TODO: have this as a publicKey
- val relayHint: String?
-): Route()
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupNostrProfileRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupNostrProfileRoute.kt
deleted file mode 100644
index c88e74a1..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupNostrProfileRoute.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-package press.mantra.compose.ui.composable.navigation.routes
-
-import kotlinx.serialization.Serializable
-
-/**
- * What the group says about itself on nostr, as the group signed it.
- *
- * The group's screen names the profile in a row; this is where it is read, with
- * the broadcast under it and, for a member holding a share of the key, the way
- * into the editor. One of the five screens the group's signed work is split
- * across -- see [GroupRelaysRoute], [GroupPostsRoute], [GroupCuratedSchemasRoute]
- * and [GroupCuratedEntriesRoute] -- each pushed from its row on the group's screen.
- */
-@Serializable
-data class GroupNostrProfileRoute(
- val activeUserPublicKey: String,
- val chatRoomId: String, // TODO: have this as a publicKey
- val relayHint: String?
-): Route()
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupPostsRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupPostsRoute.kt
deleted file mode 100644
index da9a5623..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupPostsRoute.kt
+++ /dev/null
@@ -1,15 +0,0 @@
-package press.mantra.compose.ui.composable.navigation.routes
-
-import kotlinx.serialization.Serializable
-
-/**
- * What the group has said publicly, as itself, newest first, with a broadcast
- * under each post and the way into the composer for a member holding a share
- * of the key.
- */
-@Serializable
-data class GroupPostsRoute(
- val activeUserPublicKey: String,
- val chatRoomId: String, // TODO: have this as a publicKey
- val relayHint: String?
-): Route()
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupRelaysRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupRelaysRoute.kt
deleted file mode 100644
index dfa88279..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupRelaysRoute.kt
+++ /dev/null
@@ -1,15 +0,0 @@
-package press.mantra.compose.ui.composable.navigation.routes
-
-import kotlinx.serialization.Serializable
-
-/**
- * Where the group has said it lives on nostr: its four relay lists, as the
- * group signed them, with a broadcast under each one it has agreed and the way
- * into the editor for a member holding a share of the key.
- */
-@Serializable
-data class GroupRelaysRoute(
- val activeUserPublicKey: String,
- val chatRoomId: String, // TODO: have this as a publicKey
- val relayHint: String?
-): Route()
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProposeGroupEventRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProposeGroupEventRoute.kt
deleted file mode 100644
index 71f12f23..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProposeGroupEventRoute.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package press.mantra.compose.ui.composable.navigation.routes
-
-import kotlinx.serialization.Serializable
-
-/** The untyped way into a group's signed statements: an unsigned event, pasted. */
-@Serializable
-data class ProposeGroupEventRoute(
- val activeUserPublicKey: String,
- val chatRoomId: String, // TODO: have this as a publicKey
- val relayHint: String?
-): Route()
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/buttons/BroadcastButton.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/buttons/BroadcastButton.kt
index 3912ff8b..33464200 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/buttons/BroadcastButton.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/buttons/BroadcastButton.kt
@@ -17,9 +17,10 @@ import press.mantra.compose.ui.composable.widgets.Decorative
import press.mantra.compose.ui.theme.spacing
/**
- * The way into `BroadcastGroupSignedEventScreen`, drawn the same under every
- * event the group signed -- its profile, each relay list, each post, each schema
- * and each entry -- so that it reads as one action on whichever screen it sits.
+ * The way into `BroadcastGroupSignedEventScreen`, to be drawn the same under
+ * every event the group signed so that it reads as one action on whichever
+ * screen it sits. Nothing draws it at present -- the screens that did have
+ * gone -- and it is kept, with the screen it opens, for whatever next does.
*
* Behind no gate anywhere it appears: sending what the group has already signed
* takes no share of the key, since the signature on it is the group's whoever
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt
index 730d8046..75db2d21 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt
@@ -340,25 +340,6 @@ fun ChatTranscript(
return@items
}
- // The group's own profile, signed. Same treatment
- // and for the same reason: nobody said it. Answered
- // and settled because it reports rather than asks --
- // a quorum has already signed by the time this line
- // exists.
- //
- // It leads nowhere. What a reader wants from one is
- // the profile, and the nostr profile section of the
- // group's detail screen is where that lives.
- if (localChatMessage.chatMessage.messageType in ChatMessage.GROUP_IDENTITY_TYPES) {
- RitualNotice(
- localChatMessage = localChatMessage,
- isAnswered = true,
- isSettled = true,
- onClick = {}
- )
- return@items
- }
-
// Signing lines are the same kind of thing and get
// the same treatment -- nobody said them either --
// but they lead somewhere else, because what a
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/profile/ProfileAvatar.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/profile/ProfileAvatar.kt
index d5c42b01..d0b1a198 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/profile/ProfileAvatar.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/profile/ProfileAvatar.kt
@@ -43,35 +43,6 @@ fun ProfileAvatar(
shape: Shape = CircleShape,
publicKey: String,
profile: press.mantra.compose.database.model.Profile?
-) {
- ProfileAvatar(
- size = size,
- shape = shape,
- publicKey = publicKey,
- picture = profile?.picture,
- name = profile?.displayName
- )
-}
-
-/**
- * The same avatar for a subject that has no `Profile` row.
- *
- * A group's is the case in hand: its kind:0 is signed by the group and kept as a
- * `GroupSignedEvent`, which is not a `NostrEvent` and so cannot have a `Profile`
- * hanging off it -- see `GroupNostrProfile`. The picture and the name are all this
- * ever wanted from a profile, so they are what it takes.
- *
- * [name] only reaches the content description. The tint behind a missing picture
- * comes from [publicKey], which is what keeps the same subject the same colour
- * everywhere it appears.
- */
-@Composable
-fun ProfileAvatar(
- size: Dp = 55.dp,
- shape: Shape = CircleShape,
- publicKey: String,
- picture: String?,
- name: String? = null
) {
val modifier = Modifier.size(size).clip(shape = shape)
@@ -79,7 +50,7 @@ fun ProfileAvatar(
publicKey
)
- if (picture == null) {
+ if (profile?.picture == null) {
Icon(
modifier = modifier,
imageVector = Icons.Default.AccountCircle,
@@ -106,9 +77,9 @@ fun ProfileAvatar(
)
AsyncImage(
- model = picture,
+ model = profile.picture,
modifier = modifier,
- contentDescription = "Profile picture for ${name ?: publicKey}",
+ contentDescription = "Profile picture for ${profile.displayName ?: profile.publicKey}",
placeholder = placeHolderGraphic,
fallback = fallbackGraphic,
error = errorGraphic,
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AcceptCuratedSuggestionViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AcceptCuratedSuggestionViewModel.kt
deleted file mode 100644
index d172ec2f..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AcceptCuratedSuggestionViewModel.kt
+++ /dev/null
@@ -1,324 +0,0 @@
-package press.mantra.compose.ui.view.model
-
-import androidx.compose.foundation.text.input.TextFieldState
-import androidx.compose.runtime.MutableState
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateListOf
-import androidx.compose.runtime.mutableStateMapOf
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import androidx.compose.runtime.snapshots.SnapshotStateList
-import androidx.compose.runtime.snapshots.SnapshotStateMap
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.ViewModelProvider
-import androidx.lifecycle.viewModelScope
-import androidx.lifecycle.viewmodel.initializer
-import androidx.lifecycle.viewmodel.viewModelFactory
-import co.touchlab.kermit.Logger
-import com.vitorpamplona.quartz.nip01Core.core.HexKey
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.IO
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.launch
-import press.mantra.compose.nostr.CuratedSuggestion
-import press.mantra.compose.nostr.curated.CuratedEntryProblem
-import press.mantra.compose.nostr.curated.CuratedField
-import press.mantra.compose.nostr.curated.CuratedSuggestionRef
-import press.mantra.compose.repository.ChatRepository
-import press.mantra.compose.repository.FrostSigningRepository
-import press.mantra.compose.repository.NostrRepository
-import press.mantra.compose.ui.view.state.AcceptCuratedSuggestionUIState
-
-/**
- * One suggestion, taken into the list: the entry as somebody suggested it,
- * edited by a member, and proposed as the group's own.
- *
- * Nothing here is saved. The working copy is one input per value the entry
- * has, held on this view model rather than in the composition so that the
- * window turning does not throw an edit away, and the button folds it into a
- * kind 31890 for the room's quorum to put its key to. The list changes on
- * every member's device at once, when a quorum has signed, or not at all.
- *
- * ### What is edited, and what is kept
- *
- * Curation is editorial -- the NIP says the curator may fix a year or swap a
- * poster on the way through -- so every field the schema's form asks for is
- * open. Two things are not. The `d` is kept whatever field writes it, because
- * the canonical entry SHOULD land on the suggestion's coordinate so that
- * accepting it twice revises one entry rather than making two. And a field the
- * schema marks `derived` is kept as the suggester's client filled it in: this
- * app derives nothing, and an entry a form never asked for is not one a form
- * should let a member invent.
- *
- * ### Refused before proposed
- *
- * The button runs the queue's own verifier over the event it is about to
- * propose, and refuses with the fields named. A signature is the most expensive
- * thing this app does, and a canonical entry the queue would not count -- a
- * year outside the schema's range, an option that is not one -- would be a
- * quorum spent on an entry no client shows.
- */
-class AcceptCuratedSuggestionViewModel(
- val chatRoomId: String,
- val activeUserPublicKey: HexKey,
- val relayHint: String?,
- /** The `d` of the schema whose list the entry goes into. */
- val identifier: String,
- /** The event id of the suggestion being taken up. */
- val suggestionId: String,
- initialAcceptCuratedSuggestionUIState: AcceptCuratedSuggestionUIState,
- val nostrRepository: NostrRepository,
- val chatRepository: ChatRepository,
- val frostSigningRepository: FrostSigningRepository,
-): ViewModel() {
-
- var acceptCuratedSuggestionUIState: AcceptCuratedSuggestionUIState by mutableStateOf(
- initialAcceptCuratedSuggestionUIState
- )
- private set
-
- private val logger = Logger.withTag(TAG)
-
- val isActionPending: MutableState = mutableStateOf(false)
-
- /**
- * The working copy: for each field the form asks for, one input per value.
- *
- * A field that repeats has as many inputs as the suggestion had values, and
- * can be given more; every other field has exactly one. Keyed by the
- * schema's field name, which is what the entry is keyed by too.
- */
- val inputs: SnapshotStateMap> = mutableStateMapOf()
-
- /**
- * Why the last proposal did not go out, or nothing. Set by the button and
- * left showing until the next press, since the button is the thing that
- * will clear it.
- */
- var problems: List by mutableStateOf(emptyList())
- private set
-
- private var isSeeded = false
-
- fun initiate() {
- viewModelScope.launch(Dispatchers.IO) {
- val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
- if (localChatRoom == null) {
- acceptCuratedSuggestionUIState = AcceptCuratedSuggestionUIState.Error("Couldn't find the group")
- return@launch
- }
-
- val schema = chatRepository.groupCuratedSchemas(chatRoomId).firstOrNull { it.identifier == identifier }
- if (schema == null) {
- acceptCuratedSuggestionUIState = AcceptCuratedSuggestionUIState.Error("Couldn't find that schema")
- return@launch
- }
-
- // The same reading the queue screen makes, so what is accepted here
- // is what was shown there: newest per coordinate, checked against
- // the schema, and marked when the group already has an entry for it.
- // An id the reading no longer holds is a version something newer has
- // replaced since the row was tapped, and the newer one is what the
- // queue now shows.
- val events = nostrRepository.observeNostrEventsMatching(schema.queueReadFilter()).first()
- val suggestion = CuratedSuggestion.queueOf(schema, events).firstOrNull { it.id == suggestionId }
- if (suggestion == null) {
- acceptCuratedSuggestionUIState = AcceptCuratedSuggestionUIState.Error("Couldn't find that suggestion")
- return@launch
- }
-
- acceptCuratedSuggestionUIState = AcceptCuratedSuggestionUIState.Loaded(
- localChatRoom = localChatRoom,
- schema = schema,
- suggestion = suggestion,
- canSign = localChatRoom.chatRoom.mlsGroupState != null &&
- frostSigningRepository.canSign(chatRoomId),
- )
- }
- }
-
- /**
- * Fills the working copy from the suggestion, once.
- *
- * One input per value the suggestion carries for each field the form asks
- * for, and one blank input for a field it carries nothing for, so there is
- * somewhere to type. Called from the screen rather than from [initiate],
- * because the loader is not the only way a loaded state arrives: a preview
- * and a layout test both hand one straight in. Runs once, so the effect
- * that calls it re-firing does not undo an edit.
- */
- fun seedWorkingCopy(state: AcceptCuratedSuggestionUIState.Loaded) {
- if (isSeeded) return
- isSeeded = true
-
- val entry = state.suggestion.entry
-
- editableFields(state).forEach { field ->
- val values = entry.values[field.name].orEmpty().ifEmpty { listOf("") }
- inputs[field.name] = mutableStateListOf(*values.map { TextFieldState(it) }.toTypedArray())
- }
- }
-
- /** Adds a blank input under [fieldName], for a field that repeats. */
- fun addValue(fieldName: String) {
- inputs[fieldName]?.add(TextFieldState())
- }
-
- /**
- * Drops the input at [index] under [fieldName]. The last input stays, blank
- * rather than gone, so the field is still there to be typed into.
- */
- fun removeValue(fieldName: String, index: Int) {
- val values = inputs[fieldName] ?: return
- if (index !in values.indices) return
-
- if (values.size == 1) {
- values[0] = TextFieldState()
- } else {
- values.removeAt(index)
- }
- }
-
- /**
- * The problems about [fieldName], for the field's own error state. A
- * `require-any` rule is filed under its first field, so it shows there.
- */
- fun problemsFor(fieldName: String): List = problems.filter { it.field == fieldName }
-
- /**
- * The problems that are about no field on the form: the entry as a whole,
- * or a field the form keeps rather than asks for. Shown at the top, since
- * there is no input to show them under.
- */
- fun problemsAboutTheEntry(state: AcceptCuratedSuggestionUIState.Loaded): List {
- val editable = editableFields(state).map { it.name }.toSet()
- return problems.filterNot { it.field in editable }
- }
-
- /**
- * The entry as the form has it: what was typed for every field the form
- * asks for, and what was suggested for every field it keeps.
- *
- * Keyed by field name and holding every value, which is the shape
- * `CuratedEntryEvent.canonicalTemplate` lays out as tags. Blank inputs come
- * through and are dropped there; a blank is not a value.
- */
- fun draft(state: AcceptCuratedSuggestionUIState.Loaded): Map> {
- val suggested = state.suggestion.entry.values
-
- return state.schema.schema.fields.associate { field ->
- field.name to (inputs[field.name]?.map { it.text.toString() } ?: suggested[field.name].orEmpty())
- }
- }
-
- /**
- * Opens a session over the entry, or says why not.
- *
- * Two refusals before anything is proposed and each is the caller's to
- * say: an entry the queue would refuse, which is [onInvalid] with the
- * reasons left in [problems]; and a proposal that could not be opened,
- * which is [onFailure]. Both stay on the form with the edits still in it.
- */
- fun proposeEntry(
- state: AcceptCuratedSuggestionUIState.Loaded,
- onSuccess: (sessionId: String) -> Unit,
- onInvalid: () -> Unit,
- onFailure: () -> Unit
- ) {
- // Guard against double submits. Two sessions over two versions of one
- // entry would leave the list decided by whichever quorum finished last.
- if (isActionPending.value) return
-
- val template = state.schema.canonicalTemplate(
- values = draft(state),
- source = CuratedSuggestionRef.of(state.suggestion.entry),
- )
-
- problems = state.schema.verifyCanonical(template)
- if (problems.isNotEmpty()) {
- onInvalid.invoke()
- return
- }
-
- isActionPending.value = true
-
- viewModelScope.launch(Dispatchers.IO) {
- val session = runCatching {
- frostSigningRepository.proposeSigning(
- localChatRoom = state.localChatRoom,
- userPublicKey = activeUserPublicKey,
- kind = template.kind,
- tags = template.tags,
- content = template.content,
- )
- }.onFailure { error ->
- logger.e("Failed to propose a canonical entry for ${state.schema.coordinate()}", error)
- }.getOrNull()
-
- viewModelScope.launch(Dispatchers.Main) {
- if (session != null) {
- onSuccess.invoke(session.id)
- } else {
- onFailure.invoke()
- }
- }
-
- isActionPending.value = false
- }
- }
-
- companion object {
- private const val TAG = "AcceptCuratedSuggestionViewModel"
-
- /** The tag of the field the form keeps whatever else it says: the entry's coordinate. */
- private const val IDENTIFIER_TAG = "d"
-
- /**
- * The fields the form opens for editing: the ones the schema's form asks
- * for, less the one writing to `d`. See the class note for why those two
- * are kept as they were suggested.
- */
- fun editableFields(state: AcceptCuratedSuggestionUIState.Loaded): List =
- state.schema.schema.formFields().filterNot { it.tag() == IDENTIFIER_TAG }
-
- /**
- * The fields the form shows but does not open: what the suggester's client
- * filled in, and what the entry is addressed by. In the schema's order,
- * and only the ones the suggestion has a value for, since a kept field
- * with nothing in it has nothing to show.
- */
- fun keptFields(state: AcceptCuratedSuggestionUIState.Loaded): List {
- val editable = editableFields(state).map { it.name }.toSet()
- val values = state.suggestion.entry.values
-
- return state.schema.schema.fields.filter { it.name !in editable && values[it.name].orEmpty().isNotEmpty() }
- }
-
- fun factory(
- activeUserPublicKey: HexKey,
- chatRoomId: String,
- relayHint: String?,
- identifier: String,
- suggestionId: String,
- initialAcceptCuratedSuggestionUIState: AcceptCuratedSuggestionUIState =
- AcceptCuratedSuggestionUIState.Loading,
- nostrRepository: NostrRepository,
- chatRepository: ChatRepository,
- frostSigningRepository: FrostSigningRepository
- ): ViewModelProvider.Factory = viewModelFactory {
- initializer {
- AcceptCuratedSuggestionViewModel(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = relayHint,
- identifier = identifier,
- suggestionId = suggestionId,
- initialAcceptCuratedSuggestionUIState = initialAcceptCuratedSuggestionUIState,
- nostrRepository = nostrRepository,
- chatRepository = chatRepository,
- frostSigningRepository = frostSigningRepository
- )
- }
- }
- }
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddGroupPostViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddGroupPostViewModel.kt
deleted file mode 100644
index a4fd0d0e..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddGroupPostViewModel.kt
+++ /dev/null
@@ -1,154 +0,0 @@
-package press.mantra.compose.ui.view.model
-
-import androidx.compose.foundation.text.input.TextFieldState
-import androidx.compose.runtime.MutableState
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.ViewModelProvider
-import androidx.lifecycle.viewModelScope
-import androidx.lifecycle.viewmodel.initializer
-import androidx.lifecycle.viewmodel.viewModelFactory
-import co.touchlab.kermit.Logger
-import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.nostr.GroupPost
-import press.mantra.compose.repository.ChatRepository
-import press.mantra.compose.repository.FrostSigningRepository
-import press.mantra.compose.ui.view.state.AddGroupPostUIState
-import com.vitorpamplona.quartz.nip01Core.core.HexKey
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.IO
-import kotlinx.coroutines.launch
-
-/**
- * Asks the group to say something publicly, in its own name.
- *
- * The post is not written here and does not exist yet. What goes out is a proposal
- * to sign one, and the post appears -- on every member's device at once, authored by
- * the room's own key rather than by whoever typed it -- when enough members have
- * signed. That author is the room's id: signing runs at the path the room was
- * derived at, so a post says which group made it simply by being signed.
- *
- * That is the difference from a message in the room. A message is a member speaking
- * where the group can hear; this is the group speaking where anyone can, and it
- * takes a quorum to say.
- */
-class AddGroupPostViewModel(
- val chatRoomId: String,
- val activeUserPublicKey: HexKey,
- val relayHint: String?,
- initialAddGroupPostUIState: AddGroupPostUIState,
- val chatRepository: ChatRepository,
- val frostSigningRepository: FrostSigningRepository,
-): ViewModel() {
-
- var addGroupPostUIState: AddGroupPostUIState by mutableStateOf(initialAddGroupPostUIState)
- private set
-
- private val logger = Logger.withTag(TAG)
-
- val isActionPending: MutableState = mutableStateOf(false)
-
- fun initiateAddGroupPost() {
- viewModelScope.launch(Dispatchers.IO) {
- val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
-
- addGroupPostUIState = if (localChatRoom == null) {
- AddGroupPostUIState.Error("Couldn't find the chat room")
- } else {
- AddGroupPostUIState.Loaded(
- localChatRoom = localChatRoom,
- groupNostrProfile = chatRepository.groupNostrProfile(chatRoomId),
- // A NIP-17 room has no key of its own to sign as, so it has no
- // identity to post under -- the same condition the group detail
- // screen gates the entry point on.
- canSign = localChatRoom.chatRoom.mlsGroupState != null &&
- frostSigningRepository.canSign(chatRoomId),
- )
- }
- }
- }
-
- /**
- * Opens a session over one kind:1 authored by the group.
- *
- * Refuses a blank draft rather than proposing it: a quorum signing an empty note
- * would put a post on the record that renders as nothing and cannot be told from
- * a broken one -- and kind:1 is not replaceable, so there is no un-saying it.
- * `GroupPost.newestFirstAmong` drops blanks on the way back out too, which is
- * the second line of the same defence.
- *
- * The draft is not cleared on success. This screen is replaced by the signing
- * session, and on the failure path the words are the thing worth keeping.
- */
- fun proposePost(
- localChatRoom: LocalChatRoom,
- noteField: TextFieldState,
- onSuccess: (sessionId: String) -> Unit,
- onEmpty: () -> Unit,
- onFailure: () -> Unit
- ) {
- // Guard against double submits. Posts accumulate rather than replace, so a
- // second session over the same words is a second post on the record forever.
- if (isActionPending.value) return
-
- val note = noteField.text.toString().trim()
- if (note.isBlank()) {
- onEmpty.invoke()
- return
- }
-
- isActionPending.value = true
-
- val template = GroupPost.template(note)
-
- viewModelScope.launch(Dispatchers.IO) {
- val session = runCatching {
- frostSigningRepository.proposeSigning(
- localChatRoom = localChatRoom,
- userPublicKey = activeUserPublicKey,
- kind = template.kind,
- tags = template.tags,
- content = template.content,
- )
- }.onFailure { error ->
- logger.e("Failed to propose a post for $chatRoomId", error)
- }.getOrNull()
-
- viewModelScope.launch(Dispatchers.Main) {
- if (session != null) {
- onSuccess.invoke(session.id)
- } else {
- onFailure.invoke()
- }
- }
-
- isActionPending.value = false
- }
- }
-
- companion object {
- private const val TAG = "AddGroupPostViewModel"
-
- fun factory(
- activeUserPublicKey: HexKey,
- chatRoomId: String,
- relayHint: String?,
- initialAddGroupPostUIState: AddGroupPostUIState = AddGroupPostUIState.Loading,
- chatRepository: ChatRepository,
- frostSigningRepository: FrostSigningRepository
- ): ViewModelProvider.Factory = viewModelFactory {
- initializer {
- AddGroupPostViewModel(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = relayHint,
- initialAddGroupPostUIState = initialAddGroupPostUIState,
- chatRepository = chatRepository,
- frostSigningRepository = frostSigningRepository
- )
- }
- }
- }
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModel.kt
index 1f811e9e..c4b66f41 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModel.kt
@@ -15,7 +15,9 @@ import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
+import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
+import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
@@ -32,12 +34,7 @@ import press.mantra.compose.network.relays.EventPublishTransport
import press.mantra.compose.network.relays.NostrPublishResult
import press.mantra.compose.network.relays.RelayPool
import press.mantra.compose.network.sockets.NostrIncomingMessage
-import press.mantra.compose.nostr.GroupCuratedSchema
-import press.mantra.compose.nostr.GroupRelayList
-import press.mantra.compose.nostr.GroupRelaySet
import press.mantra.compose.nostr.Relays
-import press.mantra.compose.nostr.curated.CuratedEntryEvent
-import press.mantra.compose.nostr.curated.CuratedSchemaEvent
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.ui.view.state.BroadcastGroupSignedEventUIState
import kotlin.time.Duration.Companion.milliseconds
@@ -67,11 +64,19 @@ import kotlin.time.Duration.Companion.milliseconds
*
* ### Where the relays come from
*
- * [defaultRelaysFor]: the group's own General list where it has agreed one, the
- * app's publish set where it has not, plus the indexers for a relay list and the
- * list's own relays for a schema and for an entry into it, minus anything the
- * group has blocked. The member can add and remove before sending, and the
- * seeded list is a suggestion rather than a rule.
+ * [defaultRelaysFor]: the app's own publish set, the same relays everything the
+ * app publishes as itself goes to. A group has no relay list of its own to
+ * prefer, so the seed is the same for every event of every kind. The member can
+ * add and remove before sending, and the seeded list is a suggestion rather
+ * than a rule.
+ *
+ * ### Nothing opens this screen yet
+ *
+ * The rows that carried a Broadcast button under each signed event -- the
+ * group's profile, relay lists, posts and curated lists -- have gone, and no
+ * screen that stayed has one. `BroadcastGroupSignedEventRoute` is still
+ * registered and `BroadcastButton` still built, so wiring a button in is all it
+ * takes to reach this again.
*/
class BroadcastGroupSignedEventViewModel(
val chatRoomId: String,
@@ -177,32 +182,24 @@ class BroadcastGroupSignedEventViewModel(
else -> BroadcastGroupSignedEventUIState.Loaded(
localChatRoom = localChatRoom,
signedEvent = signedEvent,
- relayLists = chatRepository.groupRelayLists(chatRoomId),
- schemas = chatRepository.groupCuratedSchemas(chatRoomId),
)
}
}
}
/**
- * Fills the list from what the group has said, once.
+ * Fills the list with the defaults, once.
*
- * Called from the screen rather than from the loader, for the reason the
- * relay editor's `seedWorkingCopy` gives: a preview and a layout test hand a
- * loaded state straight in. Once and only once, so a member who emptied the
- * list on purpose does not find it refilled by a state the view model re-emits.
+ * Called from the screen rather than from the loader, because a preview and
+ * a layout test hand a loaded state straight in. Once and only once, so a
+ * member who emptied the list on purpose does not find it refilled by a
+ * state the view model re-emits.
*/
- fun seedRelays(
- relayLists: List,
- kind: Kind,
- tags: Array>,
- content: String,
- schemas: List = emptyList(),
- ) {
+ fun seedRelays(kind: Kind) {
if (isSeeded) return
isSeeded = true
- relays.addAll(defaultRelaysFor(relayLists, kind, tags, content, schemas))
+ relays.addAll(defaultRelaysFor(kind))
}
fun clearAddFailure() {
@@ -215,7 +212,7 @@ class BroadcastGroupSignedEventViewModel(
* True when the row appeared, which is the caller's cue to clear the field.
*/
fun addRelay(url: String): Boolean {
- val relayUrl = GroupRelaySet.relayUrlOrNull(url) ?: run {
+ val relayUrl = relayUrlOrNull(url) ?: run {
addFailure = AddFailure.NotARelay
return false
}
@@ -375,66 +372,32 @@ class BroadcastGroupSignedEventViewModel(
/**
* Where an event of [kind] should go, before the member has said otherwise.
*
- * The group's General list is the answer NIP-65 gives -- its write relays,
- * where it has agreed any -- and the app's own publish set stands in for a
- * group that has not, since a list of nothing would make the button a
- * no-op on every group until somebody edits it. Two kinds add to that:
- *
- * - A relay list goes to the indexers as well. The list says where to find
- * the group, so sending it only to the relays it names is circular -- a
- * reader who already knew those relays would not need the list.
- * - A curated schema goes to the relays it names for its own replies, so a
- * client reading suggestions there finds what they are replying to.
- *
- * - An entry into a curated list goes to the same relays as its schema,
- * which the NIP makes a MUST: that is where the list's readers look,
- * and where the queue screen reads the entry back to mark the
- * suggestion it came from. The entry names its schema by coordinate,
- * so the relays are read off the group's own copy of it in [schemas].
- *
- * And nothing goes to a relay the group has blocked, whatever the other
- * lists say; that is what a blocked list is for.
+ * The app's own publish set, for every kind alike. A group says nothing
+ * about where it lives -- it signs no relay list of its own -- so there
+ * is no list of the group's to prefer and nothing of the group's to
+ * exclude, and the set the app publishes its own events to is what
+ * stops the button being a no-op on every group until somebody types a
+ * relay in. The kind is taken so a caller that has one to route by need
+ * not change its call when there is something to route on again.
*/
- fun defaultRelaysFor(
- relayLists: List,
- kind: Kind,
- tags: Array> = emptyArray(),
- content: String = "",
- schemas: List = emptyList(),
- ): List {
- val general = relayLists.firstOrNull { it.set == GroupRelaySet.General }
- ?.relays.orEmpty()
- .filter { it.write }
- .map { it.url }
+ @Suppress("UNUSED_PARAMETER")
+ fun defaultRelaysFor(kind: Kind): List =
+ Relays.eventPublishRelaySet.toList()
- val where = general.ifEmpty { Relays.eventPublishRelaySet.toList() }
+ /**
+ * [url] as a relay an event could be sent to, or null.
+ *
+ * Structural only, and deliberately narrow: `wss://` because what is
+ * sent is read by strangers over the open network and `ws://` would ask
+ * them to fetch it in the clear, and no loopback because a relay only
+ * this device can reach is not somewhere a broadcast goes. Wisp's
+ * `RelayConfig.isValidUrl` refuses the same three things.
+ */
+ fun relayUrlOrNull(url: String): NormalizedRelayUrl? {
+ val trimmed = url.trim()
+ if (!trimmed.startsWith("wss://", ignoreCase = true)) return null
- val indexers = if (kind in GroupRelaySet.KINDS) {
- Relays.DefaultIndexerRelayList.toList()
- } else {
- emptyList()
- }
-
- val schemaRelays = when (kind) {
- CuratedSchemaEvent.KIND ->
- runCatching { CuratedSchemaEvent.read(tags, content).relays }
- .getOrDefault(emptyList())
- .mapNotNull { GroupRelaySet.relayUrlOrNull(it) }
-
- CuratedEntryEvent.CANONICAL_KIND ->
- schemas.firstOrNull { it.isRepliedToBy(tags) }
- ?.schema?.relays.orEmpty()
- .mapNotNull { GroupRelaySet.relayUrlOrNull(it) }
-
- else -> emptyList()
- }
-
- val blocked = relayLists.firstOrNull { it.set == GroupRelaySet.Blocked }
- ?.relays.orEmpty()
- .map { it.url }
- .toSet()
-
- return (where + indexers + schemaRelays).distinct().filterNot { it in blocked }
+ return RelayUrlNormalizer.normalizeOrNull(trimmed)?.takeUnless { it.isLocalHost() }
}
fun factory(
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt
index ec1f9afc..3bfb3db2 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt
@@ -74,12 +74,6 @@ class ChatRoomDetailViewModel(
// Initiate new chat... and navigate to new chat...
ChatRoomDetailUIState.Error("Missing chat room")
} else {
- // One read of the capability for the two things that need it. It
- // walks the room's ceremonies looking for a share, so asking twice
- // would do the same walk twice for one answer.
- val canSign = localChatRoom.chatRoom.mlsGroupState != null &&
- chatRepository.canSign(chatRoomId)
-
ChatRoomDetailUIState.Loaded(
localChatRoom = localChatRoom,
artifacts = mantraRepository.getArtifacts(chatRoomId),
@@ -88,13 +82,8 @@ class ChatRoomDetailViewModel(
signedGroupKeyStateEvent = chatRepository.signedGroupKeyStateEvent(chatRoomId),
subgroups = chatRepository.subgroupsOf(chatRoomId),
parentChatRoomId = chatRepository.parentOf(chatRoomId),
- groupNostrProfile = chatRepository.groupNostrProfile(chatRoomId),
- groupRelayLists = chatRepository.groupRelayLists(chatRoomId),
- groupPosts = chatRepository.groupPosts(chatRoomId),
- groupCuratedSchemas = chatRepository.groupCuratedSchemas(chatRoomId),
- groupCuratedEntries = chatRepository.groupCuratedEntries(chatRoomId),
- canEditNostrProfile = canSign,
- canAddSubgroup = canSign,
+ canAddSubgroup = localChatRoom.chatRoom.mlsGroupState != null &&
+ chatRepository.canSign(chatRoomId),
)
}
}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CuratedSuggestionListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CuratedSuggestionListViewModel.kt
deleted file mode 100644
index cc981945..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CuratedSuggestionListViewModel.kt
+++ /dev/null
@@ -1,257 +0,0 @@
-package press.mantra.compose.ui.view.model
-
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.ViewModelProvider
-import androidx.lifecycle.viewModelScope
-import androidx.lifecycle.viewmodel.initializer
-import androidx.lifecycle.viewmodel.viewModelFactory
-import co.touchlab.kermit.Logger
-import com.vitorpamplona.quartz.nip01Core.core.HexKey
-import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.IO
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-import press.mantra.compose.database.model.SynchronizeNostrEventRequest
-import press.mantra.compose.nostr.CuratedSuggestion
-import press.mantra.compose.nostr.GroupCuratedSchema
-import press.mantra.compose.nostr.GroupRelayList
-import press.mantra.compose.nostr.GroupRelaySet
-import press.mantra.compose.repository.ChatRepository
-import press.mantra.compose.repository.NostrRepository
-import press.mantra.compose.ui.view.state.CuratedSuggestionListUIState
-import kotlin.time.Duration.Companion.seconds
-
-/**
- * The queue of one list the group curates, read off the relays the schema
- * names.
- *
- * A pull, the way every one-off read in this app is: a request row per relay
- * for the sync pump to drain, and a watch on the local table for what comes
- * back. Nothing here talks to a relay itself. What the screen shows is
- * therefore what this device has *stored* for the coordinate, which is also
- * what it shows with no network at all -- and what it showed last time, until
- * the relays answer again.
- *
- * ### Where the queue is read from
- *
- * The schema's `relay` tags, first. They are signed into the schema for exactly
- * this: a client that finds the list anywhere knows where its replies live and
- * cannot be sent elsewhere by an unsigned config. A schema that names none --
- * the NIP allows it and says a client may then pick its own -- is read from
- * the group's general relay list, which is where the group said it publishes,
- * and failing that from this build's default. The screen says which.
- *
- * ### What "still looking" means
- *
- * The pump marks a request sent when it goes out and processed when an event
- * comes back, and a relay holding nothing for the coordinate answers with an
- * EOSE the pump does not record. So there is no signal for "asked and empty",
- * and the screen would otherwise call every list empty for the second before
- * its rows arrived. [ASK_TIMEOUT] stands in: the queue is being asked until
- * something lands or the wait runs out, and after that an empty queue is
- * empty. The reference client does the same with a six-second timer.
- */
-class CuratedSuggestionListViewModel(
- val chatRoomId: String,
- val activeUserPublicKey: HexKey,
- val relayHint: String?,
- /** The `d` of the schema whose queue this is. */
- val identifier: String,
- initialCuratedSuggestionListUIState: CuratedSuggestionListUIState,
- val nostrRepository: NostrRepository,
- val chatRepository: ChatRepository,
-): ViewModel() {
-
- var curatedSuggestionListUIState: CuratedSuggestionListUIState by mutableStateOf(
- initialCuratedSuggestionListUIState
- )
- private set
-
- private val logger = Logger.withTag(TAG)
-
- /**
- * Serialises the read-modify-write on the state. The watch, the timer and
- * a re-ask each update one field of it from their own coroutine, and a copy
- * taken by one and written after another's is a lost update.
- */
- private val stateMutex = Mutex()
-
- private var askTimeout: Job? = null
-
- fun initiate() {
- viewModelScope.launch(Dispatchers.IO) {
- val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
- if (localChatRoom == null) {
- curatedSuggestionListUIState = CuratedSuggestionListUIState.Error("Couldn't find the group")
- return@launch
- }
-
- // Opened on a schema this device does not hold: nothing to ask for,
- // since the coordinate is the group's key and the identifier and
- // half of that came from a screen that no longer agrees.
- val schema = chatRepository.groupCuratedSchemas(chatRoomId).firstOrNull { it.identifier == identifier }
- if (schema == null) {
- curatedSuggestionListUIState = CuratedSuggestionListUIState.Error("Couldn't find that schema")
- return@launch
- }
-
- val relays = relaysToAsk(
- schema = schema,
- general = chatRepository.groupRelayLists(chatRoomId).firstOrNull { it.set == GroupRelaySet.General },
- )
-
- curatedSuggestionListUIState = CuratedSuggestionListUIState.Loaded(
- localChatRoom = localChatRoom,
- schema = schema,
- relays = relays,
- isAsking = true,
- )
-
- ask(schema, relays)
- watch(schema)
- }
- }
-
- /**
- * Asks the relays again, for a member who believes something is missing.
- *
- * The one thing the watch does not do on its own: it shows what arrives,
- * and this is how more is made to arrive. Idempotent on the stored rows --
- * an event already held is upserted, not duplicated.
- */
- fun askAgain() {
- val loaded = curatedSuggestionListUIState as? CuratedSuggestionListUIState.Loaded ?: return
-
- viewModelScope.launch(Dispatchers.IO) {
- update { it.copy(isAsking = true) }
- ask(loaded.schema, loaded.relays)
- }
- }
-
- private suspend fun ask(schema: GroupCuratedSchema, relays: List) {
- logger.d("Asking ${relays.size} relay(s) for the queue of ${schema.coordinate()}")
-
- nostrRepository.queueSynchronizeNostrEvent(requestsFor(schema, relays))
-
- askTimeout?.cancel()
- askTimeout = viewModelScope.launch(Dispatchers.IO) {
- delay(ASK_TIMEOUT)
- update { it.copy(isAsking = false) }
- }
- }
-
- /**
- * Watches the stored rows for the coordinate for as long as the screen
- * lives, re-reading the queue whole on every change. Whole rather than
- * incrementally because the reading is a merge -- newest per coordinate,
- * curated by identifier -- and a merge over a delta is a different reading.
- */
- private suspend fun watch(schema: GroupCuratedSchema) {
- nostrRepository.observeNostrEventsMatching(schema.queueReadFilter())
- .distinctUntilChanged()
- .collect { events ->
- val queue = CuratedSuggestion.queueOf(schema, events)
- logger.d("${queue.size} suggestion(s) to ${schema.coordinate()} among ${events.size} stored event(s)")
-
- update { loaded -> withQueue(loaded, queue) }
- }
- }
-
- private suspend fun update(
- change: (CuratedSuggestionListUIState.Loaded) -> CuratedSuggestionListUIState.Loaded
- ) {
- stateMutex.withLock {
- val loaded = curatedSuggestionListUIState as? CuratedSuggestionListUIState.Loaded ?: return
- curatedSuggestionListUIState = change(loaded)
- }
- }
-
- companion object {
- private const val TAG = "CuratedSuggestionListViewModel"
-
- /**
- * The schema's relays, else the group's, else the default. See the
- * class note for why in that order. Normalised so that two spellings
- * of one relay are one request, and only the group's *read* relays,
- * since this is a read.
- *
- * [general] is the group's general relay list as the screen has it, or
- * null in a room that has none of that to say. An agreed-but-empty
- * list falls through to the default rather than asking nobody: the
- * group withdrew where it publishes, not where its lists are read.
- */
- internal fun relaysToAsk(schema: GroupCuratedSchema, general: GroupRelayList?): List {
- val named = schema.schema.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it)?.url }.distinct()
- if (named.isNotEmpty()) return named
-
- val relays = general?.takeIf { it.isAgreed }?.relays ?: GroupRelaySet.General.defaults()
-
- return relays.filter { it.read }.map { it.url.url }.distinct()
- .ifEmpty { GroupRelaySet.General.defaults().map { it.url.url } }
- }
-
- /** One request per relay, each carrying the NIP's two queries for the coordinate. */
- internal fun requestsFor(schema: GroupCuratedSchema, relays: List): List =
- relays.map { relayUrl ->
- SynchronizeNostrEventRequest(
- purpose = PURPOSE,
- synchronizationFilters = schema.queueFilters(),
- relayURL = relayUrl,
- level = 0,
- )
- }
-
- /**
- * The state once [queue] has been read off the stored rows. Something
- * arriving means the relays have answered, so the asking ends there;
- * nothing arriving leaves it to the timer.
- */
- internal fun withQueue(
- loaded: CuratedSuggestionListUIState.Loaded,
- queue: List,
- ): CuratedSuggestionListUIState.Loaded = loaded.copy(
- suggestions = queue,
- isAsking = loaded.isAsking && queue.isEmpty(),
- )
-
- /** The purpose the sync rows are filed under, for anything that counts them. */
- const val PURPOSE = "curated-queue"
-
- /**
- * How long an empty queue is "being asked" before it is called empty.
- * Longer than the reference client's six seconds because the request
- * queues behind the pump's four subscription slots before it goes out.
- */
- internal val ASK_TIMEOUT = 10.seconds
-
- fun factory(
- chatRoomId: String,
- activeUserPublicKey: HexKey,
- relayHint: String?,
- identifier: String,
- initialCuratedSuggestionListUIState: CuratedSuggestionListUIState = CuratedSuggestionListUIState.Loading,
- nostrRepository: NostrRepository,
- chatRepository: ChatRepository,
- ): ViewModelProvider.Factory = viewModelFactory {
- initializer {
- CuratedSuggestionListViewModel(
- chatRoomId = chatRoomId,
- activeUserPublicKey = activeUserPublicKey,
- relayHint = relayHint,
- identifier = identifier,
- initialCuratedSuggestionListUIState = initialCuratedSuggestionListUIState,
- nostrRepository = nostrRepository,
- chatRepository = chatRepository,
- )
- }
- }
- }
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupCuratedSchemaViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupCuratedSchemaViewModel.kt
deleted file mode 100644
index 03847a12..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupCuratedSchemaViewModel.kt
+++ /dev/null
@@ -1,544 +0,0 @@
-package press.mantra.compose.ui.view.model
-
-import androidx.compose.runtime.MutableState
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateListOf
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.ViewModelProvider
-import androidx.lifecycle.viewModelScope
-import androidx.lifecycle.viewmodel.initializer
-import androidx.lifecycle.viewmodel.viewModelFactory
-import co.touchlab.kermit.Logger
-import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.extensions.bech32ToHexOrNull
-import press.mantra.compose.nostr.GroupCuratedSchema
-import press.mantra.compose.nostr.GroupRelaySet
-import press.mantra.compose.nostr.curated.CuratedField
-import press.mantra.compose.nostr.curated.CuratedFieldType
-import press.mantra.compose.nostr.curated.CuratedSchema
-import press.mantra.compose.nostr.curated.CuratedSchemaEvent
-import press.mantra.compose.nostr.curated.CuratedSchemaProblem
-import press.mantra.compose.nostr.curated.CuratedVisibility
-import press.mantra.compose.repository.ChatRepository
-import press.mantra.compose.repository.FrostSigningRepository
-import press.mantra.compose.ui.view.state.EditGroupCuratedSchemaUIState
-import com.vitorpamplona.quartz.nip01Core.core.HexKey
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.IO
-import kotlinx.coroutines.launch
-
-/**
- * One of the group's curated schemas, edited locally and proposed as one event.
- *
- * Nothing here is saved. The parts of the schema that are lists -- its fields,
- * its rules, its suggesters, its relays -- live on this view model as snapshot
- * state so that a bottom sheet opening over the form, or the window turning,
- * does not throw an edit away; the identity fields are text and live in the
- * composition like every other form's. The button folds both halves into a
- * [CuratedSchema], refuses it for any reason a reader would, and otherwise opens
- * one signing session over it. The schema changes on every member's device at
- * once, when a quorum has signed, or not at all.
- *
- * ### Edit and replace
- *
- * Kind 31889 is addressable, so proposing a schema under an identifier the group
- * already signed one for *replaces* it -- which is what an edit is, and why the
- * identifier is the one thing the editor will not let an edit change. A new
- * identifier is a new list, with a queue of its own, and the group's screen
- * offers that as adding a schema rather than as editing this one.
- *
- * The comparison for "nothing changed" is against the schema as the group signed
- * it, normalized on both sides, because re-signing an identical schema would put
- * a second statement on the record with a newer timestamp: harmless, since the
- * kind is replaceable, and still a lie about when the group last decided
- * anything about the list.
- */
-class EditGroupCuratedSchemaViewModel(
- val chatRoomId: String,
- val activeUserPublicKey: HexKey,
- val relayHint: String?,
- /** The `d` of the schema being edited, or null for a new one. */
- val identifier: String?,
- initialEditGroupCuratedSchemaUIState: EditGroupCuratedSchemaUIState,
- val chatRepository: ChatRepository,
- val frostSigningRepository: FrostSigningRepository,
-): ViewModel() {
-
- var editGroupCuratedSchemaUIState: EditGroupCuratedSchemaUIState by mutableStateOf(
- initialEditGroupCuratedSchemaUIState
- )
- private set
-
- private val logger = Logger.withTag(TAG)
-
- val isActionPending: MutableState = mutableStateOf(false)
-
- /** Who may suggest. Always set: the editor never proposes a schema without one. */
- var visibility: CuratedVisibility by mutableStateOf(CuratedVisibility.Public)
- private set
-
- /** The working copy of the schema's fields, in the order they will be written. */
- val fields = mutableStateListOf()
-
- /**
- * The working copy of the `require-any` rules, each a set of field names.
- *
- * A rule may hold fewer than two names while it is being built; one that
- * still does when the button is pressed says nothing and is dropped rather
- * than refused -- see `CuratedSchema.requireAny`.
- */
- val requireAny = mutableStateListOf>()
-
- /** The working copy of the `p` tags: pubkeys besides the group that may suggest. */
- val suggesters = mutableStateListOf()
-
- /** The working copy of the `relay` tags. */
- val relays = mutableStateListOf()
-
- /** The field the sheet is open on, or null while it is closed. */
- var editingField: FieldEdit? by mutableStateOf(null)
- private set
-
- /**
- * Why the last proposal did not go out, or nothing. Set by the button and
- * left showing until the next press, since the button is the thing that
- * will clear it.
- */
- var problems: List by mutableStateOf(emptyList())
- private set
-
- /** Why the last relay was refused, or null. Cleared by the next keystroke. */
- var addRelayFailure: AddRelayFailure? by mutableStateOf(null)
- private set
-
- /** Why the last suggester was refused, or null. Cleared by the next keystroke. */
- var addSuggesterFailure: AddSuggesterFailure? by mutableStateOf(null)
- private set
-
- private var isSeeded = false
-
- /**
- * A field on the sheet. [index] is its place in [fields], or null for one
- * being added -- which is also what decides whether committing it appends
- * or replaces.
- */
- data class FieldEdit(val index: Int?, val field: CuratedField)
-
- enum class AddRelayFailure {
- /** Not a `wss://` URL, or one pointing at this machine. */
- NotARelay,
-
- /** Already named, which is a no-op rather than a mistake. */
- AlreadyListed,
- }
-
- enum class AddSuggesterFailure {
- /** Neither an npub nor 64 hex characters. */
- NotAPubkey,
-
- AlreadyListed,
- }
-
- /** Why a field cannot be committed as it stands. */
- enum class FieldProblem {
- NameMissing,
-
- /** A field name is the key a form collects a value under, and a key is one word. */
- NameHasSpaces,
-
- /** Another field already answers to that name. */
- NameTaken,
- }
-
- fun initiateEditGroupCuratedSchema() {
- viewModelScope.launch(Dispatchers.IO) {
- val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
-
- editGroupCuratedSchemaUIState = if (localChatRoom == null) {
- EditGroupCuratedSchemaUIState.Error("Couldn't find the chat room")
- } else {
- val existing = identifier?.let { wanted ->
- chatRepository.groupCuratedSchemas(chatRoomId).firstOrNull { it.identifier == wanted }
- }
-
- if (identifier != null && existing == null) {
- // Opened on a schema this device does not hold. Nothing to
- // retry and nothing to edit; offering a blank form under that
- // identifier would propose a replacement for a list nobody
- // here has read.
- EditGroupCuratedSchemaUIState.Error("Couldn't find that schema")
- } else {
- EditGroupCuratedSchemaUIState.Loaded(
- localChatRoom = localChatRoom,
- existing = existing,
- defaultRelays = defaultRelays(chatRoomId),
- canSign = localChatRoom.chatRoom.mlsGroupState != null &&
- frostSigningRepository.canSign(chatRoomId),
- )
- }
- }
- }
- }
-
- /**
- * Where a new schema says its list lives: the relays the group has said it
- * writes to, since that is where its canonical entries would be read from.
- *
- * The NIP-65 list where the group has agreed one, else that set's defaults
- * -- which is this build's own relay, and the same thing a group opening the
- * relay editor for the first time is shown. A schema with no relays is one
- * whose suggestions could go anywhere, so the seed is worth getting right.
- */
- private suspend fun defaultRelays(chatRoomId: String): List {
- val general = chatRepository.groupRelayLists(chatRoomId)
- .firstOrNull { it.set == GroupRelaySet.General }
- ?: return GroupRelaySet.General.defaults().map { it.url.url }
-
- return (if (general.isAgreed) general.relays else general.set.defaults())
- .filter { it.write }
- .map { it.url.url }
- }
-
- /**
- * Fills the working copy from the schema the group signed, once.
- *
- * An existing schema starts at itself. A new one starts at the two fields
- * every list has -- the identifier and the title, which `CuratedSchema.normalized`
- * would put back anyway, shown up front so that what the member sees is what
- * the group will sign -- and at the group's own relays.
- *
- * Called from the screen rather than from [initiateEditGroupCuratedSchema],
- * because the loader is not the only way a loaded state arrives: a preview
- * and a layout test both hand one straight in. Runs once, so the effect that
- * calls it re-firing does not undo an edit.
- */
- fun seedWorkingCopy(state: EditGroupCuratedSchemaUIState.Loaded) {
- if (isSeeded) return
- isSeeded = true
-
- val schema = state.existing?.schema
- if (schema != null) {
- visibility = schema.visibility ?: CuratedVisibility.Public
- fields.addAll(schema.fields)
- requireAny.addAll(schema.requireAny)
- suggesters.addAll(schema.suggesters)
- relays.addAll(schema.relays)
- } else {
- visibility = CuratedVisibility.Public
- fields.addAll(listOf(CuratedField.IDENTIFIER, CuratedField.TITLE))
- relays.addAll(state.defaultRelays)
- }
- }
-
- fun selectVisibility(visibility: CuratedVisibility) {
- this.visibility = visibility
- }
-
- /**
- * Whether [field] is one of the two every list must have.
- *
- * Decided by the tag it writes to, not its name, because that is what the
- * NIP's rule is about: a list needs a field writing to `d` and one writing
- * to `title`, whatever they are called. Such a field cannot be removed or
- * made optional here -- `CuratedSchema.normalized` would only put it back.
- */
- fun isMandatory(field: CuratedField): Boolean =
- CuratedField.MANDATORY.any { (tag, _) -> field.tag() == tag }
-
- /** Opens the sheet on a blank field. Text, because most fields are. */
- fun startAddingField() {
- editingField = FieldEdit(
- index = null,
- field = CuratedField(name = "", type = CuratedFieldType.Text, isRequired = false),
- )
- }
-
- fun startEditingField(index: Int) {
- fields.getOrNull(index)?.let { editingField = FieldEdit(index = index, field = it) }
- }
-
- fun dismissFieldEditor() {
- editingField = null
- }
-
- /**
- * Puts [field] into the working copy at the place the sheet was opened on,
- * or says why it cannot.
- *
- * Null means it went in and the sheet is closed. A rename follows through to
- * the rules that named the field, since a rule naming a field that no longer
- * exists would silently never be satisfied.
- */
- fun commitField(field: CuratedField): FieldProblem? {
- val edit = editingField ?: return null
-
- val name = field.name.trim()
- if (name.isEmpty()) return FieldProblem.NameMissing
- if (name.any { it.isWhitespace() }) return FieldProblem.NameHasSpaces
- if (fields.withIndex().any { (index, other) -> index != edit.index && other.name == name }) {
- return FieldProblem.NameTaken
- }
-
- val committed = field.copy(name = name)
-
- if (edit.index == null) {
- fields.add(committed)
- } else {
- val previous = fields[edit.index]
- fields[edit.index] = committed
- if (previous.name != name) {
- requireAny.indices.forEach { group ->
- requireAny[group] = requireAny[group].map { if (it == previous.name) name else it }
- }
- }
- }
-
- editingField = null
- return null
- }
-
- /** Drops the field at [index] and takes it out of any rule that named it. */
- fun removeField(index: Int) {
- val removed = fields.getOrNull(index) ?: return
- if (isMandatory(removed)) return
-
- fields.removeAt(index)
- requireAny.indices.forEach { group ->
- requireAny[group] = requireAny[group].filterNot { it == removed.name }
- }
- editingField = null
- }
-
- fun addRequireAnyGroup() {
- requireAny.add(emptyList())
- }
-
- fun toggleRequireAny(group: Int, fieldName: String) {
- val names = requireAny.getOrNull(group) ?: return
- requireAny[group] = if (fieldName in names) names - fieldName else names + fieldName
- }
-
- fun removeRequireAnyGroup(group: Int) {
- if (group in requireAny.indices) requireAny.removeAt(group)
- }
-
- /**
- * Adds [input] to the suggesters, or says why it did not.
- *
- * True when the row appeared, which is the caller's cue to clear the field.
- */
- fun addSuggester(input: String): Boolean {
- val pubkey = pubkeyOrNull(input) ?: run {
- addSuggesterFailure = AddSuggesterFailure.NotAPubkey
- return false
- }
-
- if (pubkey in suggesters) {
- addSuggesterFailure = AddSuggesterFailure.AlreadyListed
- return false
- }
-
- suggesters.add(pubkey)
- addSuggesterFailure = null
- return true
- }
-
- fun removeSuggester(pubkey: HexKey) {
- suggesters.remove(pubkey)
- }
-
- fun clearAddSuggesterFailure() {
- addSuggesterFailure = null
- }
-
- /**
- * Adds [url] to the relays, or says why it did not.
- *
- * Held to the same rule as the group's relay lists -- `wss://`, not this
- * machine -- rather than to the NIP's looser one, because this is a relay
- * the group is about to sign for strangers to publish to. See
- * `GroupRelaySet.relayUrlOrNull`.
- */
- fun addRelay(url: String): Boolean {
- val relay = GroupRelaySet.relayUrlOrNull(url) ?: run {
- addRelayFailure = AddRelayFailure.NotARelay
- return false
- }
-
- // Compared normalized, because a relay signed into an existing schema is
- // kept as it was written and the normalizer adds a trailing slash: the
- // same host with and without one is one relay, not two.
- if (relays.any { GroupRelaySet.relayUrlOrNull(it) == relay }) {
- addRelayFailure = AddRelayFailure.AlreadyListed
- return false
- }
-
- relays.add(relay.url)
- addRelayFailure = null
- return true
- }
-
- fun removeRelay(url: String) {
- relays.remove(url)
- }
-
- fun clearAddRelayFailure() {
- addRelayFailure = null
- }
-
- /**
- * The schema as the form has it: the text fields trimmed and the lists as
- * they stand, with a rule too short to mean anything left out.
- */
- fun draft(
- identifier: String,
- title: String,
- name: String,
- description: String,
- picture: String,
- domain: String,
- ): CuratedSchema = CuratedSchema(
- identifier = identifier.trim(),
- title = title.trim(),
- name = name.trim(),
- description = description.trim(),
- visibility = visibility,
- picture = picture.trim().ifEmpty { null },
- domain = domain.trim().ifEmpty { null }?.let(CuratedSchemaEvent::normalizeDomain),
- fields = fields.toList(),
- requireAny = requireAny.filter { it.size > 1 },
- suggesters = suggesters.toList(),
- relays = relays.toList(),
- )
-
- /**
- * Opens a session over the schema, or says why not.
- *
- * Three refusals before anything is proposed and each is the caller's to
- * say: a schema a reader would reject, which is [onInvalid] with the
- * reasons; an edit that changed nothing, which is [onNothingToPropose]; and
- * a proposal that could not be opened, which is [onFailure]. All of them
- * stay on the form with the edits still in it.
- */
- fun proposeSchema(
- localChatRoom: LocalChatRoom,
- existing: GroupCuratedSchema?,
- identifier: String,
- title: String,
- name: String,
- description: String,
- picture: String,
- domain: String,
- onSuccess: (sessionId: String) -> Unit,
- onInvalid: () -> Unit,
- onNothingToPropose: () -> Unit,
- onFailure: () -> Unit
- ) {
- // Guard against double submits. Two sessions over two versions of one
- // schema would leave the list's form decided by whichever quorum
- // finished last.
- if (isActionPending.value) return
-
- val schema = draft(
- // The identifier of an existing schema is the coordinate every
- // suggestion to it replies to, so an edit keeps it whatever the
- // field says -- and the field is read-only to say so.
- identifier = existing?.identifier ?: identifier,
- title = title,
- name = name,
- description = description,
- picture = picture,
- domain = domain,
- )
-
- problems = schema.problems()
- if (problems.isNotEmpty()) {
- onInvalid.invoke()
- return
- }
-
- if (existing != null && existing.schema == schema.normalized()) {
- onNothingToPropose.invoke()
- return
- }
-
- isActionPending.value = true
-
- val template = GroupCuratedSchema.template(schema)
-
- viewModelScope.launch(Dispatchers.IO) {
- val session = runCatching {
- frostSigningRepository.proposeSigning(
- localChatRoom = localChatRoom,
- userPublicKey = activeUserPublicKey,
- kind = template.kind,
- tags = template.tags,
- content = template.content,
- )
- }.onFailure { error ->
- logger.e("Failed to propose a curated schema for $chatRoomId", error)
- }.getOrNull()
-
- viewModelScope.launch(Dispatchers.Main) {
- if (session != null) {
- onSuccess.invoke(session.id)
- } else {
- onFailure.invoke()
- }
- }
-
- isActionPending.value = false
- }
- }
-
- companion object {
- private const val TAG = "EditGroupCuratedSchemaViewModel"
-
- /**
- * [input] as a pubkey the schema could name, or null.
- *
- * An npub -- with or without a `nostr:` in front, the way it is pasted
- * from most clients -- or 64 hex characters in either case. Nothing else,
- * and in particular not a NIP-05 address, since resolving one is a
- * network round trip and this is a field on a form.
- */
- fun pubkeyOrNull(input: String): HexKey? {
- val trimmed = input.trim().removePrefix("nostr:")
-
- val hex = if (trimmed.startsWith("npub1", ignoreCase = true)) {
- trimmed.bech32ToHexOrNull()
- } else {
- trimmed.lowercase()
- }
-
- return hex?.takeIf { candidate ->
- candidate.length == 64 && candidate.all { it in '0'..'9' || it in 'a'..'f' }
- }
- }
-
- fun factory(
- activeUserPublicKey: HexKey,
- chatRoomId: String,
- relayHint: String?,
- identifier: String?,
- initialEditGroupCuratedSchemaUIState: EditGroupCuratedSchemaUIState =
- EditGroupCuratedSchemaUIState.Loading,
- chatRepository: ChatRepository,
- frostSigningRepository: FrostSigningRepository
- ): ViewModelProvider.Factory = viewModelFactory {
- initializer {
- EditGroupCuratedSchemaViewModel(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = relayHint,
- identifier = identifier,
- initialEditGroupCuratedSchemaUIState = initialEditGroupCuratedSchemaUIState,
- chatRepository = chatRepository,
- frostSigningRepository = frostSigningRepository
- )
- }
- }
- }
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupNostrProfileViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupNostrProfileViewModel.kt
deleted file mode 100644
index ff8d81f6..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupNostrProfileViewModel.kt
+++ /dev/null
@@ -1,161 +0,0 @@
-package press.mantra.compose.ui.view.model
-
-import androidx.compose.foundation.text.input.TextFieldState
-import androidx.compose.runtime.MutableState
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.ViewModelProvider
-import androidx.lifecycle.viewModelScope
-import androidx.lifecycle.viewmodel.initializer
-import androidx.lifecycle.viewmodel.viewModelFactory
-import co.touchlab.kermit.Logger
-import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.nostr.GroupNostrProfile
-import press.mantra.compose.repository.ChatRepository
-import press.mantra.compose.repository.FrostSigningRepository
-import press.mantra.compose.ui.view.state.EditGroupNostrProfileUIState
-import com.vitorpamplona.quartz.nip01Core.core.HexKey
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.IO
-import kotlinx.coroutines.launch
-
-/**
- * Asks the group to sign what it says about itself on nostr.
- *
- * The room's id is the pubkey the group signs as, so the kind:0 this proposes is
- * the profile the rest of nostr would read for that key -- authored by the group
- * rather than by whoever filled the form in. Nothing is saved when the button is
- * pressed: what goes out is a proposal, and the profile changes on every member's
- * device at once when a quorum has signed it.
- */
-class EditGroupNostrProfileViewModel(
- val chatRoomId: String,
- val activeUserPublicKey: HexKey,
- val relayHint: String?,
- initialEditGroupNostrProfileUIState: EditGroupNostrProfileUIState,
- val chatRepository: ChatRepository,
- val frostSigningRepository: FrostSigningRepository,
-): ViewModel() {
-
- var editGroupNostrProfileUIState: EditGroupNostrProfileUIState by mutableStateOf(
- initialEditGroupNostrProfileUIState
- )
- private set
-
- private val logger = Logger.withTag(TAG)
-
- val isActionPending: MutableState = mutableStateOf(false)
-
- fun initiateEditGroupNostrProfile() {
- viewModelScope.launch(Dispatchers.IO) {
- val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
-
- editGroupNostrProfileUIState = if (localChatRoom == null) {
- EditGroupNostrProfileUIState.Error("Couldn't find the chat room")
- } else {
- EditGroupNostrProfileUIState.Loaded(
- localChatRoom = localChatRoom,
- groupNostrProfile = chatRepository.groupNostrProfile(chatRoomId),
- // A NIP-17 room has no key of its own to sign as, so it has no
- // nostr identity to describe -- the same condition the group
- // detail screen gates the entry point on.
- canSign = localChatRoom.chatRoom.mlsGroupState != null &&
- frostSigningRepository.canSign(chatRoomId),
- )
- }
- }
- }
-
- /**
- * Opens a session over one kind:0 for the group's own key.
- *
- * A blank field is not a field left alone: it clears what the group had said,
- * because that is the only way to unsay it. [GroupNostrProfile.template] hands
- * every field to `MetadataEvent`, whose rule for these arguments is that empty
- * deletes the key -- and it builds on the group's newest profile, so a field
- * this form does not offer is carried across rather than wiped.
- *
- * The fields are not cleared on success, unlike the add forms': a profile is
- * standing state rather than a thing being added, and this screen is replaced
- * by the signing session anyway.
- */
- fun proposeNostrProfile(
- localChatRoom: LocalChatRoom,
- groupNostrProfile: GroupNostrProfile?,
- nameField: TextFieldState,
- aboutField: TextFieldState,
- pictureField: TextFieldState,
- websiteField: TextFieldState,
- nip05Field: TextFieldState,
- lud16Field: TextFieldState,
- onSuccess: (sessionId: String) -> Unit,
- onFailure: () -> Unit
- ) {
- // Guard against double submits from repeated taps. A second session over a
- // second kind:0 would leave the group's profile decided by whichever
- // quorum finished last.
- if (isActionPending.value) return
- isActionPending.value = true
-
- val template = GroupNostrProfile.template(
- latest = groupNostrProfile,
- name = nameField.text.toString().trim(),
- about = aboutField.text.toString().trim(),
- picture = pictureField.text.toString().trim(),
- website = websiteField.text.toString().trim(),
- nip05 = nip05Field.text.toString().trim(),
- lud16 = lud16Field.text.toString().trim(),
- )
-
- viewModelScope.launch(Dispatchers.IO) {
- val session = runCatching {
- frostSigningRepository.proposeSigning(
- localChatRoom = localChatRoom,
- userPublicKey = activeUserPublicKey,
- kind = template.kind,
- tags = template.tags,
- content = template.content,
- )
- }.onFailure { error ->
- logger.e("Failed to propose a nostr profile for $chatRoomId", error)
- }.getOrNull()
-
- viewModelScope.launch(Dispatchers.Main) {
- if (session != null) {
- onSuccess.invoke(session.id)
- } else {
- onFailure.invoke()
- }
- }
-
- isActionPending.value = false
- }
- }
-
- companion object {
- private const val TAG = "EditGroupNostrProfileViewModel"
-
- fun factory(
- activeUserPublicKey: HexKey,
- chatRoomId: String,
- relayHint: String?,
- initialEditGroupNostrProfileUIState: EditGroupNostrProfileUIState =
- EditGroupNostrProfileUIState.Loading,
- chatRepository: ChatRepository,
- frostSigningRepository: FrostSigningRepository
- ): ViewModelProvider.Factory = viewModelFactory {
- initializer {
- EditGroupNostrProfileViewModel(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = relayHint,
- initialEditGroupNostrProfileUIState = initialEditGroupNostrProfileUIState,
- chatRepository = chatRepository,
- frostSigningRepository = frostSigningRepository
- )
- }
- }
- }
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupRelaysViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupRelaysViewModel.kt
deleted file mode 100644
index fbae1c94..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupRelaysViewModel.kt
+++ /dev/null
@@ -1,299 +0,0 @@
-package press.mantra.compose.ui.view.model
-
-import androidx.compose.runtime.MutableState
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateMapOf
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.ViewModelProvider
-import androidx.lifecycle.viewModelScope
-import androidx.lifecycle.viewmodel.initializer
-import androidx.lifecycle.viewmodel.viewModelFactory
-import co.touchlab.kermit.Logger
-import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.nostr.GroupRelay
-import press.mantra.compose.nostr.GroupRelayList
-import press.mantra.compose.nostr.GroupRelaySet
-import press.mantra.compose.repository.ChatRepository
-import press.mantra.compose.repository.FrostSigningRepository
-import press.mantra.compose.ui.view.state.EditGroupRelaysUIState
-import com.vitorpamplona.quartz.nip01Core.core.HexKey
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.IO
-import kotlinx.coroutines.launch
-
-/**
- * The group's relay lists, edited locally and proposed together.
- *
- * Nothing here is saved. The working copy lives in [working] until the button is
- * pressed, and what the button does is open one signing session over the sets that
- * changed -- so the group's relay lists change on every member's device at once,
- * when a quorum has signed, or not at all.
- *
- * ### Why one session rather than one per set
- *
- * Wisp publishes a tab at a time because a person's client signs for itself and
- * there is nothing to coordinate. Here every signature costs a quorum's attention,
- * and four separate sessions for one sitting at one screen would ask for it four
- * times over what is plainly a single decision: where this group lives.
- *
- * The batch carries **only the sets that differ**, which is what keeps that honest.
- * Re-signing a list nobody touched would put a second, identical statement on the
- * record with a newer timestamp -- harmless, since these are replaceable, and still
- * a lie about when the group last decided anything.
- *
- * `FrostSigningManager` documents the cost of batching: a batch is all-or-nothing
- * and so only as available as its worst item. These four items are the same size,
- * the same shape and signed by the same quorum in the same second, so there is no
- * worst one to be dragged down by.
- */
-class EditGroupRelaysViewModel(
- val chatRoomId: String,
- val activeUserPublicKey: HexKey,
- val relayHint: String?,
- initialEditGroupRelaysUIState: EditGroupRelaysUIState,
- val chatRepository: ChatRepository,
- val frostSigningRepository: FrostSigningRepository,
-): ViewModel() {
-
- var editGroupRelaysUIState: EditGroupRelaysUIState by mutableStateOf(
- initialEditGroupRelaysUIState
- )
- private set
-
- private val logger = Logger.withTag(TAG)
-
- val isActionPending: MutableState = mutableStateOf(false)
-
- /**
- * The working copy: what the lists would be if the group agreed.
- *
- * A snapshot map so the editor recomposes as rows are added and removed, and
- * held on the view model rather than in the composition so that switching tabs
- * -- which is a pager page changing -- does not throw away an edit.
- */
- val working = mutableStateMapOf>()
-
- /** Which set the editor is showing, and so what the add field adds to. */
- var selectedSet: GroupRelaySet by mutableStateOf(GroupRelaySet.entries.first())
- private set
-
- /**
- * Why the last add did not happen, or null. Cleared by the next keystroke.
- *
- * Named rather than silent: Wisp's add button does nothing at all for a URL it
- * refuses, and "nothing happened" is indistinguishable from a missed tap.
- */
- var addFailure: AddFailure? by mutableStateOf(null)
- private set
-
- enum class AddFailure {
- /** Not a `wss://` URL, or one pointing at this machine. */
- NotARelay,
-
- /** Already in this set, which is a no-op rather than a mistake. */
- AlreadyListed,
- }
-
- fun initiateEditGroupRelays() {
- viewModelScope.launch(Dispatchers.IO) {
- val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
-
- editGroupRelaysUIState = if (localChatRoom == null) {
- EditGroupRelaysUIState.Error("Couldn't find the chat room")
- } else {
- EditGroupRelaysUIState.Loaded(
- localChatRoom = localChatRoom,
- signed = chatRepository.groupRelayLists(chatRoomId),
- canSign = localChatRoom.chatRoom.mlsGroupState != null &&
- frostSigningRepository.canSign(chatRoomId),
- )
- }
- }
- }
-
- /**
- * Fills the working copy from what the group signed, leaving edits alone.
- *
- * A set the group has agreed starts at its list; one it has not starts at the
- * set's defaults, which is what puts this build's own relay in front of a group
- * setting up for the first time rather than an empty list. See
- * `GroupRelaySet.defaults`.
- *
- * Called from the screen rather than from [initiateEditGroupRelays], because
- * the loader is not the only way a loaded state arrives -- a preview and a
- * layout test both hand one straight in, and seeding only on the load path gave
- * both of them an editor with nothing in it while the app worked fine. The
- * screen has the state either way.
- *
- * **Only fills what is missing**, so running again after the member has typed
- * something does not undo it. That matters because the effect that calls this
- * is keyed on a state the view model can re-emit.
- */
- fun seedWorkingCopy(signed: List) {
- signed.forEach { list ->
- if (list.set in working) return@forEach
-
- working[list.set] = if (list.isAgreed) list.relays else list.set.defaults()
- }
- }
-
- fun selectSet(set: GroupRelaySet) {
- selectedSet = set
- addFailure = null
- }
-
- fun clearAddFailure() {
- addFailure = null
- }
-
- fun relaysOf(set: GroupRelaySet): List = working[set] ?: emptyList()
-
- /**
- * Adds [url] to the selected set, or says why it did not.
- *
- * True when the row appeared, which is the caller's cue to clear the field.
- */
- fun addRelay(url: String): Boolean {
- val relayUrl = GroupRelaySet.relayUrlOrNull(url) ?: run {
- addFailure = AddFailure.NotARelay
- return false
- }
-
- val current = relaysOf(selectedSet)
- if (current.any { it.url == relayUrl }) {
- addFailure = AddFailure.AlreadyListed
- return false
- }
-
- working[selectedSet] = current + GroupRelay(relayUrl)
- addFailure = null
- return true
- }
-
- fun removeRelay(set: GroupRelaySet, relay: GroupRelay) {
- working[set] = relaysOf(set).filterNot { it.url == relay.url }
- }
-
- /**
- * Turns a NIP-65 marker on or off, refusing to turn off the last one.
- *
- * A relay that is neither read nor write has no tag in NIP-65 -- see
- * `GroupRelaySet.template` -- so the state does not exist to be put the group's
- * signature on. What it would mean is that the relay is not in the list, and
- * removing it is the row's own button.
- */
- fun toggleRead(set: GroupRelaySet, relay: GroupRelay) =
- toggle(set, relay) { it.copy(read = !it.read) }
-
- fun toggleWrite(set: GroupRelaySet, relay: GroupRelay) =
- toggle(set, relay) { it.copy(write = !it.write) }
-
- private fun toggle(
- set: GroupRelaySet,
- relay: GroupRelay,
- change: (GroupRelay) -> GroupRelay,
- ) {
- working[set] = relaysOf(set).map {
- if (it.url != relay.url) return@map it
-
- change(it).takeIf { changed -> changed.read || changed.write } ?: it
- }
- }
-
- /**
- * The sets whose working copy says something different to what the group
- * signed, in the order the editor shows them.
- *
- * Order-sensitive on purpose. A relay list is written in the order it is read
- * back, and a member who moved a relay to the front meant to; comparing as sets
- * would call that no change and quietly refuse to propose it.
- *
- * A set the group has never agreed counts as changed as soon as it holds
- * anything, so a first-time group's seeded defaults are a real proposal rather
- * than an accident, and an empty one it has never agreed counts as unchanged --
- * there is nothing to say.
- */
- fun changedSets(signed: List): List =
- signed.filter { list -> relaysOf(list.set) != list.relays }
- .filterNot { list -> !list.isAgreed && relaysOf(list.set).isEmpty() }
- .map { it.set }
-
- /**
- * Opens one session over every set that changed.
- *
- * [onNothingToPropose] rather than a session over nothing:
- * `proposeSigningBatch` refuses an empty batch outright, and a member who has
- * changed nothing is asking a question rather than making a mistake.
- */
- fun proposeRelayLists(
- localChatRoom: LocalChatRoom,
- signed: List,
- onSuccess: (sessionId: String) -> Unit,
- onNothingToPropose: () -> Unit,
- onFailure: () -> Unit
- ) {
- // Guard against double submits. Two sessions over two versions of one relay
- // list would leave the group's answer decided by whichever quorum finished
- // last.
- if (isActionPending.value) return
-
- val changed = changedSets(signed)
- if (changed.isEmpty()) {
- onNothingToPropose.invoke()
- return
- }
-
- isActionPending.value = true
-
- val templates = changed.map { set -> set.template(relaysOf(set)) }
-
- viewModelScope.launch(Dispatchers.IO) {
- val session = runCatching {
- frostSigningRepository.proposeSigningBatch(
- localChatRoom = localChatRoom,
- userPublicKey = activeUserPublicKey,
- events = templates,
- )
- }.onFailure { error ->
- logger.e("Failed to propose relay lists for $chatRoomId", error)
- }.getOrNull()
-
- viewModelScope.launch(Dispatchers.Main) {
- if (session != null) {
- onSuccess.invoke(session.id)
- } else {
- onFailure.invoke()
- }
- }
-
- isActionPending.value = false
- }
- }
-
- companion object {
- private const val TAG = "EditGroupRelaysViewModel"
-
- fun factory(
- activeUserPublicKey: HexKey,
- chatRoomId: String,
- relayHint: String?,
- initialEditGroupRelaysUIState: EditGroupRelaysUIState =
- EditGroupRelaysUIState.Loading,
- chatRepository: ChatRepository,
- frostSigningRepository: FrostSigningRepository
- ): ViewModelProvider.Factory = viewModelFactory {
- initializer {
- EditGroupRelaysViewModel(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = relayHint,
- initialEditGroupRelaysUIState = initialEditGroupRelaysUIState,
- chatRepository = chatRepository,
- frostSigningRepository = frostSigningRepository
- )
- }
- }
- }
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupCuratedEntriesViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupCuratedEntriesViewModel.kt
deleted file mode 100644
index b9412b92..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupCuratedEntriesViewModel.kt
+++ /dev/null
@@ -1,65 +0,0 @@
-package press.mantra.compose.ui.view.model
-
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.ViewModelProvider
-import androidx.lifecycle.viewModelScope
-import androidx.lifecycle.viewmodel.initializer
-import androidx.lifecycle.viewmodel.viewModelFactory
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.IO
-import kotlinx.coroutines.launch
-import press.mantra.compose.repository.ChatRepository
-import press.mantra.compose.ui.view.state.GroupCuratedEntriesUIState
-
-/**
- * The entries the group has accepted into its lists, read once for the screen
- * that shows them. One read rather than a watch, for the reason
- * `GroupNostrProfileViewModel` gives. Nothing here asks whether this device
- * can sign, because nothing on the screen would change if it could.
- */
-class GroupCuratedEntriesViewModel(
- val chatRoomId: String,
- initialGroupCuratedEntriesUIState: GroupCuratedEntriesUIState,
- val chatRepository: ChatRepository,
-): ViewModel() {
-
- var groupCuratedEntriesUIState: GroupCuratedEntriesUIState by mutableStateOf(
- initialGroupCuratedEntriesUIState
- )
- private set
-
- fun initiate() {
- viewModelScope.launch(Dispatchers.IO) {
- val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
-
- groupCuratedEntriesUIState = if (localChatRoom == null) {
- GroupCuratedEntriesUIState.Error("Couldn't find the group")
- } else {
- GroupCuratedEntriesUIState.Loaded(
- localChatRoom = localChatRoom,
- groupCuratedEntries = chatRepository.groupCuratedEntries(chatRoomId),
- )
- }
- }
- }
-
- companion object {
- fun factory(
- chatRoomId: String,
- initialGroupCuratedEntriesUIState: GroupCuratedEntriesUIState =
- GroupCuratedEntriesUIState.Loading,
- chatRepository: ChatRepository,
- ): ViewModelProvider.Factory = viewModelFactory {
- initializer {
- GroupCuratedEntriesViewModel(
- chatRoomId = chatRoomId,
- initialGroupCuratedEntriesUIState = initialGroupCuratedEntriesUIState,
- chatRepository = chatRepository,
- )
- }
- }
- }
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupCuratedSchemasViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupCuratedSchemasViewModel.kt
deleted file mode 100644
index e3988d9b..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupCuratedSchemasViewModel.kt
+++ /dev/null
@@ -1,66 +0,0 @@
-package press.mantra.compose.ui.view.model
-
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.ViewModelProvider
-import androidx.lifecycle.viewModelScope
-import androidx.lifecycle.viewmodel.initializer
-import androidx.lifecycle.viewmodel.viewModelFactory
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.IO
-import kotlinx.coroutines.launch
-import press.mantra.compose.repository.ChatRepository
-import press.mantra.compose.ui.view.state.GroupCuratedSchemasUIState
-
-/**
- * The schemas of the lists the group curates, read once for the screen that
- * shows them. One read rather than a watch, for the reason
- * `GroupNostrProfileViewModel` gives.
- */
-class GroupCuratedSchemasViewModel(
- val chatRoomId: String,
- initialGroupCuratedSchemasUIState: GroupCuratedSchemasUIState,
- val chatRepository: ChatRepository,
-): ViewModel() {
-
- var groupCuratedSchemasUIState: GroupCuratedSchemasUIState by mutableStateOf(
- initialGroupCuratedSchemasUIState
- )
- private set
-
- fun initiate() {
- viewModelScope.launch(Dispatchers.IO) {
- val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
-
- groupCuratedSchemasUIState = if (localChatRoom == null) {
- GroupCuratedSchemasUIState.Error("Couldn't find the group")
- } else {
- GroupCuratedSchemasUIState.Loaded(
- localChatRoom = localChatRoom,
- groupCuratedSchemas = chatRepository.groupCuratedSchemas(chatRoomId),
- canSign = localChatRoom.chatRoom.mlsGroupState != null &&
- chatRepository.canSign(chatRoomId),
- )
- }
- }
- }
-
- companion object {
- fun factory(
- chatRoomId: String,
- initialGroupCuratedSchemasUIState: GroupCuratedSchemasUIState =
- GroupCuratedSchemasUIState.Loading,
- chatRepository: ChatRepository,
- ): ViewModelProvider.Factory = viewModelFactory {
- initializer {
- GroupCuratedSchemasViewModel(
- chatRoomId = chatRoomId,
- initialGroupCuratedSchemasUIState = initialGroupCuratedSchemasUIState,
- chatRepository = chatRepository,
- )
- }
- }
- }
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupNostrProfileViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupNostrProfileViewModel.kt
deleted file mode 100644
index 2d7b4315..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupNostrProfileViewModel.kt
+++ /dev/null
@@ -1,71 +0,0 @@
-package press.mantra.compose.ui.view.model
-
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.ViewModelProvider
-import androidx.lifecycle.viewModelScope
-import androidx.lifecycle.viewmodel.initializer
-import androidx.lifecycle.viewmodel.viewModelFactory
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.IO
-import kotlinx.coroutines.launch
-import press.mantra.compose.repository.ChatRepository
-import press.mantra.compose.ui.view.state.GroupNostrProfileUIState
-
-/**
- * The group's nostr profile, read once for the screen that shows it.
- *
- * One read rather than a watch, the way the group's screen reads it: a profile
- * changes when a quorum signs a new one, which lands as a group event this
- * device processes, and the screen is re-entered -- and re-read -- on the way
- * back from the signing session that made it.
- */
-class GroupNostrProfileViewModel(
- val chatRoomId: String,
- initialGroupNostrProfileUIState: GroupNostrProfileUIState,
- val chatRepository: ChatRepository,
-): ViewModel() {
-
- var groupNostrProfileUIState: GroupNostrProfileUIState by mutableStateOf(
- initialGroupNostrProfileUIState
- )
- private set
-
- fun initiate() {
- viewModelScope.launch(Dispatchers.IO) {
- val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
-
- groupNostrProfileUIState = if (localChatRoom == null) {
- GroupNostrProfileUIState.Error("Couldn't find the group")
- } else {
- GroupNostrProfileUIState.Loaded(
- localChatRoom = localChatRoom,
- groupNostrProfile = chatRepository.groupNostrProfile(chatRoomId),
- // A NIP-17 room is excluded by the first half: it has no key
- // of its own to sign as, so nothing to hold a share of.
- canSign = localChatRoom.chatRoom.mlsGroupState != null &&
- chatRepository.canSign(chatRoomId),
- )
- }
- }
- }
-
- companion object {
- fun factory(
- chatRoomId: String,
- initialGroupNostrProfileUIState: GroupNostrProfileUIState =
- GroupNostrProfileUIState.Loading,
- chatRepository: ChatRepository,
- ): ViewModelProvider.Factory = viewModelFactory {
- initializer {
- GroupNostrProfileViewModel(
- chatRoomId = chatRoomId,
- initialGroupNostrProfileUIState = initialGroupNostrProfileUIState,
- chatRepository = chatRepository,
- )
- }
- }
- }
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupPostsViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupPostsViewModel.kt
deleted file mode 100644
index 20fae7f9..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupPostsViewModel.kt
+++ /dev/null
@@ -1,64 +0,0 @@
-package press.mantra.compose.ui.view.model
-
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.ViewModelProvider
-import androidx.lifecycle.viewModelScope
-import androidx.lifecycle.viewmodel.initializer
-import androidx.lifecycle.viewmodel.viewModelFactory
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.IO
-import kotlinx.coroutines.launch
-import press.mantra.compose.repository.ChatRepository
-import press.mantra.compose.ui.view.state.GroupPostsUIState
-
-/**
- * The group's posts, read once for the screen that shows them, with the
- * profile that bylines them. One read rather than a watch, for the reason
- * `GroupNostrProfileViewModel` gives.
- */
-class GroupPostsViewModel(
- val chatRoomId: String,
- initialGroupPostsUIState: GroupPostsUIState,
- val chatRepository: ChatRepository,
-): ViewModel() {
-
- var groupPostsUIState: GroupPostsUIState by mutableStateOf(initialGroupPostsUIState)
- private set
-
- fun initiate() {
- viewModelScope.launch(Dispatchers.IO) {
- val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
-
- groupPostsUIState = if (localChatRoom == null) {
- GroupPostsUIState.Error("Couldn't find the group")
- } else {
- GroupPostsUIState.Loaded(
- localChatRoom = localChatRoom,
- groupPosts = chatRepository.groupPosts(chatRoomId),
- groupNostrProfile = chatRepository.groupNostrProfile(chatRoomId),
- canSign = localChatRoom.chatRoom.mlsGroupState != null &&
- chatRepository.canSign(chatRoomId),
- )
- }
- }
- }
-
- companion object {
- fun factory(
- chatRoomId: String,
- initialGroupPostsUIState: GroupPostsUIState = GroupPostsUIState.Loading,
- chatRepository: ChatRepository,
- ): ViewModelProvider.Factory = viewModelFactory {
- initializer {
- GroupPostsViewModel(
- chatRoomId = chatRoomId,
- initialGroupPostsUIState = initialGroupPostsUIState,
- chatRepository = chatRepository,
- )
- }
- }
- }
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupRelaysViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupRelaysViewModel.kt
deleted file mode 100644
index 8c6c4152..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupRelaysViewModel.kt
+++ /dev/null
@@ -1,62 +0,0 @@
-package press.mantra.compose.ui.view.model
-
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.ViewModelProvider
-import androidx.lifecycle.viewModelScope
-import androidx.lifecycle.viewmodel.initializer
-import androidx.lifecycle.viewmodel.viewModelFactory
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.IO
-import kotlinx.coroutines.launch
-import press.mantra.compose.repository.ChatRepository
-import press.mantra.compose.ui.view.state.GroupRelaysUIState
-
-/**
- * The group's relay lists, read once for the screen that shows them. One read
- * rather than a watch, for the reason `GroupNostrProfileViewModel` gives.
- */
-class GroupRelaysViewModel(
- val chatRoomId: String,
- initialGroupRelaysUIState: GroupRelaysUIState,
- val chatRepository: ChatRepository,
-): ViewModel() {
-
- var groupRelaysUIState: GroupRelaysUIState by mutableStateOf(initialGroupRelaysUIState)
- private set
-
- fun initiate() {
- viewModelScope.launch(Dispatchers.IO) {
- val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
-
- groupRelaysUIState = if (localChatRoom == null) {
- GroupRelaysUIState.Error("Couldn't find the group")
- } else {
- GroupRelaysUIState.Loaded(
- localChatRoom = localChatRoom,
- groupRelayLists = chatRepository.groupRelayLists(chatRoomId),
- canSign = localChatRoom.chatRoom.mlsGroupState != null &&
- chatRepository.canSign(chatRoomId),
- )
- }
- }
- }
-
- companion object {
- fun factory(
- chatRoomId: String,
- initialGroupRelaysUIState: GroupRelaysUIState = GroupRelaysUIState.Loading,
- chatRepository: ChatRepository,
- ): ViewModelProvider.Factory = viewModelFactory {
- initializer {
- GroupRelaysViewModel(
- chatRoomId = chatRoomId,
- initialGroupRelaysUIState = initialGroupRelaysUIState,
- chatRepository = chatRepository,
- )
- }
- }
- }
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProposeGroupEventViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProposeGroupEventViewModel.kt
deleted file mode 100644
index 87f8f029..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProposeGroupEventViewModel.kt
+++ /dev/null
@@ -1,158 +0,0 @@
-package press.mantra.compose.ui.view.model
-
-import androidx.compose.runtime.MutableState
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.ViewModelProvider
-import androidx.lifecycle.viewModelScope
-import androidx.lifecycle.viewmodel.initializer
-import androidx.lifecycle.viewmodel.viewModelFactory
-import co.touchlab.kermit.Logger
-import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.nostr.GroupEventProposal
-import press.mantra.compose.repository.ChatRepository
-import press.mantra.compose.repository.FrostSigningRepository
-import press.mantra.compose.ui.view.state.ProposeGroupEventUIState
-import com.vitorpamplona.quartz.nip01Core.core.HexKey
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.IO
-import kotlinx.coroutines.launch
-
-/**
- * Asks the group to sign an event a member pasted.
- *
- * Nothing is built here; the paste is the event. What this decides is whether
- * the group should be asked at all -- `GroupEventProposal.read` refuses a paste
- * the group's screen could never show, so a quorum is not spent on an event that
- * would be signed, filed and seen by nobody -- and it hands the kind, tags and
- * content to the same `proposeSigning` every typed editor uses. The author and
- * the time are the session's to fill in, which is how a pasted `pubkey` or
- * `created_at` comes to be ignored: they are never passed along.
- *
- * Where the event lands is decided by nothing here. `FrostSigningManager.complete`
- * files every signed event as a `GroupSignedEvent`, and the group's screen reads
- * its profile, posts, relay lists and schemas off those by kind. A pasted kind 0
- * becomes the profile the same way a kind 0 from the profile form does, because
- * by the time either is signed there is no telling them apart.
- */
-class ProposeGroupEventViewModel(
- val chatRoomId: String,
- val activeUserPublicKey: HexKey,
- val relayHint: String?,
- initialProposeGroupEventUIState: ProposeGroupEventUIState,
- val chatRepository: ChatRepository,
- val frostSigningRepository: FrostSigningRepository,
-): ViewModel() {
-
- var proposeGroupEventUIState: ProposeGroupEventUIState by mutableStateOf(
- initialProposeGroupEventUIState
- )
- private set
-
- private val logger = Logger.withTag(TAG)
-
- val isActionPending: MutableState = mutableStateOf(false)
-
- fun initiateProposeGroupEvent() {
- viewModelScope.launch(Dispatchers.IO) {
- val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
-
- proposeGroupEventUIState = if (localChatRoom == null) {
- ProposeGroupEventUIState.Error("Couldn't find the chat room")
- } else {
- ProposeGroupEventUIState.Loaded(
- localChatRoom = localChatRoom,
- groupNostrProfile = chatRepository.groupNostrProfile(chatRoomId),
- // A NIP-17 room has no key of its own to sign as, so it has no
- // statements to make -- the same condition the group detail
- // screen gates the entry point on.
- canSign = localChatRoom.chatRoom.mlsGroupState != null &&
- frostSigningRepository.canSign(chatRoomId),
- )
- }
- }
- }
-
- /**
- * Opens a session over the pasted event, or says why not.
- *
- * [onRefused] carries the reading's own reason, which the screen has been
- * showing under the paste all along; it is repeated here so pressing the
- * button on a refused paste is answered rather than ignored. Both refusals
- * and a failed proposal stay on the paste: the JSON is the thing worth
- * keeping.
- */
- fun proposeEvent(
- localChatRoom: LocalChatRoom,
- json: String,
- onSuccess: (sessionId: String) -> Unit,
- onRefused: (GroupEventProposal.Refused) -> Unit,
- onFailure: () -> Unit
- ) {
- // Guard against double submits. A pasted kind:1 accumulates like any
- // other post, so a second session over the same paste is a second post
- // on the record forever.
- if (isActionPending.value) return
-
- val ready = when (val reading = GroupEventProposal.read(json)) {
- is GroupEventProposal.Ready -> reading
- is GroupEventProposal.Refused -> {
- onRefused.invoke(reading)
- return
- }
- }
-
- isActionPending.value = true
-
- viewModelScope.launch(Dispatchers.IO) {
- val session = runCatching {
- frostSigningRepository.proposeSigning(
- localChatRoom = localChatRoom,
- userPublicKey = activeUserPublicKey,
- kind = ready.kind,
- tags = ready.tags,
- content = ready.content,
- )
- }.onFailure { error ->
- logger.e("Failed to propose a pasted kind ${ready.kind} event for $chatRoomId", error)
- }.getOrNull()
-
- viewModelScope.launch(Dispatchers.Main) {
- if (session != null) {
- onSuccess.invoke(session.id)
- } else {
- onFailure.invoke()
- }
- }
-
- isActionPending.value = false
- }
- }
-
- companion object {
- private const val TAG = "ProposeGroupEventViewModel"
-
- fun factory(
- activeUserPublicKey: HexKey,
- chatRoomId: String,
- relayHint: String?,
- initialProposeGroupEventUIState: ProposeGroupEventUIState =
- ProposeGroupEventUIState.Loading,
- chatRepository: ChatRepository,
- frostSigningRepository: FrostSigningRepository
- ): ViewModelProvider.Factory = viewModelFactory {
- initializer {
- ProposeGroupEventViewModel(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = relayHint,
- initialProposeGroupEventUIState = initialProposeGroupEventUIState,
- chatRepository = chatRepository,
- frostSigningRepository = frostSigningRepository
- )
- }
- }
- }
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AcceptCuratedSuggestionUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AcceptCuratedSuggestionUIState.kt
deleted file mode 100644
index 381447b2..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AcceptCuratedSuggestionUIState.kt
+++ /dev/null
@@ -1,34 +0,0 @@
-package press.mantra.compose.ui.view.state
-
-import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.nostr.CuratedSuggestion
-import press.mantra.compose.nostr.GroupCuratedSchema
-
-sealed interface AcceptCuratedSuggestionUIState {
- data class Loaded(
- val localChatRoom: LocalChatRoom,
- /** The list the entry would go into, as the group last signed it. */
- val schema: GroupCuratedSchema,
- /**
- * The suggestion being taken up, as the queue screen read it: the entry
- * keyed by the schema's fields, who suggested it, and whether the group
- * already has. The form starts as this, and the canonical entry points
- * back at it.
- */
- val suggestion: CuratedSuggestion,
- /**
- * Whether this device holds a share of the group's key.
- *
- * False leaves the form writable and the button inert, for the reason
- * the other editors give: reading and editing cost nothing, and only a
- * share-holder can open the session that would sign.
- */
- val canSign: Boolean = false,
- ): AcceptCuratedSuggestionUIState
-
- data class Error(
- val message: String
- ): AcceptCuratedSuggestionUIState
-
- data object Loading: AcceptCuratedSuggestionUIState
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddGroupPostUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddGroupPostUIState.kt
deleted file mode 100644
index 4bcbffb3..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddGroupPostUIState.kt
+++ /dev/null
@@ -1,33 +0,0 @@
-package press.mantra.compose.ui.view.state
-
-import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.nostr.GroupNostrProfile
-
-sealed interface AddGroupPostUIState {
- data class Loaded(
- val localChatRoom: LocalChatRoom,
- /**
- * Who the group says it is, for the byline over the draft.
- *
- * A post is signed by the group and read as the group, so the composer
- * shows whose voice is being written in -- which is not the member typing.
- * Null in a group that has not described itself, and the byline falls back
- * to the room.
- */
- val groupNostrProfile: GroupNostrProfile? = null,
- /**
- * Whether this device holds a share of the group's key.
- *
- * False leaves the draft writable and the button inert, for the same reason
- * the other two editors do: reading and composing cost nothing, and only a
- * share-holder can open the session that would sign it.
- */
- val canSign: Boolean = false,
- ): AddGroupPostUIState
-
- data class Error(
- val message: String
- ): AddGroupPostUIState
-
- data object Loading: AddGroupPostUIState
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/BroadcastGroupSignedEventUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/BroadcastGroupSignedEventUIState.kt
index 277d2c9e..bd472a3b 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/BroadcastGroupSignedEventUIState.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/BroadcastGroupSignedEventUIState.kt
@@ -2,27 +2,12 @@ package press.mantra.compose.ui.view.state
import press.mantra.compose.database.model.GroupSignedEvent
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.nostr.GroupCuratedSchema
-import press.mantra.compose.nostr.GroupRelayList
sealed interface BroadcastGroupSignedEventUIState {
data class Loaded(
val localChatRoom: LocalChatRoom,
/** The event to send, as the group signed it. What goes on the wire is this, byte for byte. */
val signedEvent: GroupSignedEvent,
- /**
- * Where the group has said it lives, which is what the relay picker starts
- * from: the General list's write relays when the group has agreed one, and
- * never a relay the group has blocked. See
- * `BroadcastGroupSignedEventViewModel.defaultRelaysFor`.
- */
- val relayLists: List = emptyList(),
- /**
- * The lists the group curates, for an entry into one of them: the NIP
- * says a canonical entry goes to its schema's relays, and the entry
- * names its schema by coordinate rather than carrying the relays itself.
- */
- val schemas: List = emptyList(),
): BroadcastGroupSignedEventUIState
data class Error(
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt
index 7222beae..80f08814 100755
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt
@@ -7,11 +7,6 @@ import press.mantra.compose.database.model.GroupSignedEvent
import press.mantra.compose.database.model.MantraArtifact
import press.mantra.compose.database.model.MantraDialect
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.nostr.GroupCuratedEntry
-import press.mantra.compose.nostr.GroupCuratedSchema
-import press.mantra.compose.nostr.GroupNostrProfile
-import press.mantra.compose.nostr.GroupPost
-import press.mantra.compose.nostr.GroupRelayList
sealed interface ChatRoomDetailUIState {
data class Loaded(
@@ -55,78 +50,6 @@ sealed interface ChatRoomDetailUIState {
* really is a subgroup.
*/
val parentChatRoomId: HexKey? = null,
- /**
- * The kind:0 the group signed about itself, or null while it has signed
- * none.
- *
- * Its row sits under the room's identity and above its work, because that
- * is what it is: the name and the picture the rest of nostr sees on the
- * key the signing key row names. The row shows the name and the address;
- * the profile itself is read on `GroupNostrProfileScreen`. Null is an
- * ordinary state and the row says so -- a group has an identity from the
- * moment it has a key and describes it whenever somebody gets round to it.
- */
- val groupNostrProfile: GroupNostrProfile? = null,
- /**
- * Where the group has said it lives on nostr, one entry per relay set.
- *
- * Beside the profile because they are the same kind of fact -- the group's
- * own account of itself, signed by the group -- and read in the same breath
- * by anybody checking whether the group is reachable. The row counts how
- * many the group has agreed; the lists are read on `GroupRelaysScreen`.
- *
- * Empty only in a room that has none of this to say. Every other case has
- * four entries, some of them unsigned; see `ChatRepository.groupRelayLists`.
- */
- val groupRelayLists: List = emptyList(),
- /**
- * What the group has said publicly, as itself, newest first.
- *
- * Under the relay lists because it is the thing they are *for*: the profile
- * says who the group is, the relay lists say where to find it, and these
- * are what a reader would find there. The row counts them; they are read
- * on `GroupPostsScreen`. Empty in a group that has said nothing, which is
- * every group until somebody proposes one.
- */
- val groupPosts: List = emptyList(),
- /**
- * The lists this group curates, as the schemas it signed for them, most
- * recently signed first.
- *
- * Under the posts because a schema is the other thing a group publishes
- * for strangers to answer: a post is the group speaking, a schema is the
- * group asking -- for entries to a list it will then curate. The row
- * counts them; they are read on `GroupCuratedSchemasScreen`. Empty in a
- * group that curates nothing, which is every group until somebody
- * proposes a schema.
- */
- val groupCuratedSchemas: List = emptyList(),
- /**
- * The entries this group has accepted into the lists it curates, most
- * recently signed first.
- *
- * Under the schemas because they are what a schema is *for*: the schema
- * asks, strangers suggest, and these are what the group decided to
- * stand behind. The row counts them; they are read on
- * `GroupCuratedEntriesScreen`. Empty in a group that has accepted
- * nothing, which is every group until a quorum signs an entry.
- */
- val groupCuratedEntries: List = emptyList(),
- /**
- * Whether this device could sign an event for the group, which on this
- * screen gates the paste at the end of the identity block. Each of the
- * five screens the rows open reads the same capability for its own gate.
- *
- * The same capability [canAddSubgroup] wants, and wanted for the same
- * reason: proposing anything in the group's name is a signature by the
- * group, and `FrostSigningManager.proposeSigningBatch` throws for a
- * device holding no share of the key. It is not about permission -- any
- * member with a share may propose -- and the quorum is what decides.
- *
- * A NIP-17 room is excluded by the same condition, since it has no key of
- * its own to sign as and so no nostr identity to describe.
- */
- val canEditNostrProfile: Boolean = false,
/**
* Whether this device could open a subgroup here at all.
*
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/CuratedSuggestionListUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/CuratedSuggestionListUIState.kt
deleted file mode 100644
index 5d41d950..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/CuratedSuggestionListUIState.kt
+++ /dev/null
@@ -1,40 +0,0 @@
-package press.mantra.compose.ui.view.state
-
-import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.nostr.CuratedSuggestion
-import press.mantra.compose.nostr.GroupCuratedSchema
-
-sealed interface CuratedSuggestionListUIState {
- data class Loaded(
- val localChatRoom: LocalChatRoom,
- /** The list whose queue this is, as the group last signed it. */
- val schema: GroupCuratedSchema,
- /**
- * Where the queue was read from: the relays the schema names, or the
- * group's own where it names none. On the screen because a queue read
- * from the wrong relays is an empty queue with no way to tell it from
- * a list nobody has written to.
- */
- val relays: List = emptyList(),
- /** Everything suggested to the list, newest first. */
- val suggestions: List = emptyList(),
- /**
- * Whether the relays are still being asked.
- *
- * A pull through the sync queue has no end-of-stored-events the screen
- * can see, so this is bounded by time rather than by an answer: true
- * from the request going out until the first suggestion lands or the
- * wait runs out. While it is true and nothing has arrived, the screen
- * is looking rather than empty.
- */
- val isAsking: Boolean = false,
- ): CuratedSuggestionListUIState {
- val curatedCount: Int get() = suggestions.count { it.isCurated }
- }
-
- data class Error(
- val message: String
- ): CuratedSuggestionListUIState
-
- data object Loading: CuratedSuggestionListUIState
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupCuratedSchemaUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupCuratedSchemaUIState.kt
deleted file mode 100644
index d217e1b4..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupCuratedSchemaUIState.kt
+++ /dev/null
@@ -1,42 +0,0 @@
-package press.mantra.compose.ui.view.state
-
-import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.nostr.GroupCuratedSchema
-
-sealed interface EditGroupCuratedSchemaUIState {
- data class Loaded(
- val localChatRoom: LocalChatRoom,
- /**
- * The schema being edited, as the group last signed it, or null for a new
- * one.
- *
- * Kept beside the working copy rather than replaced by it, because the
- * button proposes only when something differs -- and "differs" needs
- * both halves. Null also locks nothing: a new schema's identifier is the
- * member's to choose, and an existing one's is not.
- */
- val existing: GroupCuratedSchema? = null,
- /**
- * The relays a new schema starts with: where the group has said it
- * publishes, since that is where its list would be read.
- *
- * Only a new schema is seeded from these. An existing one has its own
- * relays signed into it, and those are the ones an edit starts from.
- */
- val defaultRelays: List = emptyList(),
- /**
- * Whether this device holds a share of the group's key.
- *
- * False leaves the form writable and the button inert, for the same
- * reason the other editors do: drafting a schema costs nothing, and only
- * a share-holder can open the session that would sign it.
- */
- val canSign: Boolean = false,
- ): EditGroupCuratedSchemaUIState
-
- data class Error(
- val message: String
- ): EditGroupCuratedSchemaUIState
-
- data object Loading: EditGroupCuratedSchemaUIState
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupNostrProfileUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupNostrProfileUIState.kt
deleted file mode 100644
index 77f7ee64..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupNostrProfileUIState.kt
+++ /dev/null
@@ -1,34 +0,0 @@
-package press.mantra.compose.ui.view.state
-
-import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.nostr.GroupNostrProfile
-
-sealed interface EditGroupNostrProfileUIState {
- data class Loaded(
- val localChatRoom: LocalChatRoom,
- /**
- * What the group has already said about itself, or null the first time.
- *
- * The form starts from it, and so does the event: an edit is built with
- * `MetadataEvent.updateFromPast` so a field this app does not offer survives
- * being edited rather than being dropped by it. See
- * [GroupNostrProfile.template].
- */
- val groupNostrProfile: GroupNostrProfile? = null,
- /**
- * Whether this device holds a share of the group's key.
- *
- * False leaves the form readable and the button inert, which is the honest
- * shape: the profile is worth reading either way, and a member without a
- * share cannot open a session for one. See
- * `ChatRoomDetailUIState.canEditNostrProfile`.
- */
- val canSign: Boolean = false,
- ): EditGroupNostrProfileUIState
-
- data class Error(
- val message: String
- ): EditGroupNostrProfileUIState
-
- data object Loading: EditGroupNostrProfileUIState
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupRelaysUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupRelaysUIState.kt
deleted file mode 100644
index 08f7963e..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupRelaysUIState.kt
+++ /dev/null
@@ -1,33 +0,0 @@
-package press.mantra.compose.ui.view.state
-
-import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.nostr.GroupRelayList
-
-sealed interface EditGroupRelaysUIState {
- data class Loaded(
- val localChatRoom: LocalChatRoom,
- /**
- * What the group has signed for each set, which is what an edit is measured
- * against.
- *
- * Kept beside the working copy rather than replaced by it, because the
- * button only proposes the sets that actually differ -- and "differ" needs
- * both halves. See `EditGroupRelaysViewModel.changedSets`.
- */
- val signed: List = emptyList(),
- /**
- * Whether this device holds a share of the group's key.
- *
- * False leaves the lists readable and the button inert: where the group
- * says it lives is worth reading whoever is looking, and proposing a change
- * to it is a signature only a share-holder can start.
- */
- val canSign: Boolean = false,
- ): EditGroupRelaysUIState
-
- data class Error(
- val message: String
- ): EditGroupRelaysUIState
-
- data object Loading: EditGroupRelaysUIState
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupCuratedEntriesUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupCuratedEntriesUIState.kt
deleted file mode 100644
index 88622196..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupCuratedEntriesUIState.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-package press.mantra.compose.ui.view.state
-
-import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.nostr.GroupCuratedEntry
-
-sealed interface GroupCuratedEntriesUIState {
- data class Loaded(
- val localChatRoom: LocalChatRoom,
- /**
- * The entries this group has accepted into the lists it curates, most
- * recently signed first. Empty in a group that has accepted nothing,
- * which is every group until a quorum signs an entry.
- *
- * No capability beside it, because nothing on the screen needs one:
- * reading what the group decided takes no share of the key, and
- * neither does repeating its signature to a relay.
- */
- val groupCuratedEntries: List = emptyList(),
- ): GroupCuratedEntriesUIState
-
- data class Error(
- val message: String
- ): GroupCuratedEntriesUIState
-
- data object Loading: GroupCuratedEntriesUIState
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupCuratedSchemasUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupCuratedSchemasUIState.kt
deleted file mode 100644
index 079c8c33..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupCuratedSchemasUIState.kt
+++ /dev/null
@@ -1,31 +0,0 @@
-package press.mantra.compose.ui.view.state
-
-import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.nostr.GroupCuratedSchema
-
-sealed interface GroupCuratedSchemasUIState {
- data class Loaded(
- val localChatRoom: LocalChatRoom,
- /**
- * The lists this group curates, as the schemas it signed for them, most
- * recently signed first. Empty in a group that curates nothing, which
- * is every group until somebody proposes a schema.
- */
- val groupCuratedSchemas: List = emptyList(),
- /**
- * Whether this device holds a share of the group's key.
- *
- * False leaves the schemas readable -- and their queues, which take no
- * share to read -- and hides both ways into the editor, the card's and
- * the button's, for the reason `GroupNostrProfileUIState.Loaded.canSign`
- * gives.
- */
- val canSign: Boolean = false,
- ): GroupCuratedSchemasUIState
-
- data class Error(
- val message: String
- ): GroupCuratedSchemasUIState
-
- data object Loading: GroupCuratedSchemasUIState
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupNostrProfileUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupNostrProfileUIState.kt
deleted file mode 100644
index 8facb25f..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupNostrProfileUIState.kt
+++ /dev/null
@@ -1,35 +0,0 @@
-package press.mantra.compose.ui.view.state
-
-import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.nostr.GroupNostrProfile
-
-sealed interface GroupNostrProfileUIState {
- data class Loaded(
- val localChatRoom: LocalChatRoom,
- /**
- * The kind:0 the group signed about itself, or null while it has signed
- * none.
- *
- * Null is an ordinary state and the screen says so -- a group has an
- * identity from the moment it has a key and describes it whenever
- * somebody gets round to it.
- */
- val groupNostrProfile: GroupNostrProfile? = null,
- /**
- * Whether this device holds a share of the group's key.
- *
- * False leaves the profile readable and hides the way into the editor:
- * proposing one is a signature by the group, and
- * `FrostSigningManager.proposeSigningBatch` throws for a device holding
- * no share. It is not about permission -- any member with a share may
- * propose -- and the quorum is what decides.
- */
- val canSign: Boolean = false,
- ): GroupNostrProfileUIState
-
- data class Error(
- val message: String
- ): GroupNostrProfileUIState
-
- data object Loading: GroupNostrProfileUIState
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupPostsUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupPostsUIState.kt
deleted file mode 100644
index e3d4358b..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupPostsUIState.kt
+++ /dev/null
@@ -1,40 +0,0 @@
-package press.mantra.compose.ui.view.state
-
-import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.nostr.GroupNostrProfile
-import press.mantra.compose.nostr.GroupPost
-
-sealed interface GroupPostsUIState {
- data class Loaded(
- val localChatRoom: LocalChatRoom,
- /**
- * What the group has said publicly, as itself, newest first. Empty in
- * a group that has said nothing, which is every group until somebody
- * proposes a post.
- */
- val groupPosts: List = emptyList(),
- /**
- * The group's profile, for the byline on every post.
- *
- * Read alongside the posts because a post is the one thing here a
- * reader might otherwise attribute to whichever member proposed it,
- * and the group's own name and picture are what say it was the group.
- * Null where the group has not described itself, and the byline falls
- * back to the key.
- */
- val groupNostrProfile: GroupNostrProfile? = null,
- /**
- * Whether this device holds a share of the group's key.
- *
- * False leaves the posts readable and hides the way into the composer,
- * for the reason `GroupNostrProfileUIState.Loaded.canSign` gives.
- */
- val canSign: Boolean = false,
- ): GroupPostsUIState
-
- data class Error(
- val message: String
- ): GroupPostsUIState
-
- data object Loading: GroupPostsUIState
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupRelaysUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupRelaysUIState.kt
deleted file mode 100644
index 48cd1902..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupRelaysUIState.kt
+++ /dev/null
@@ -1,31 +0,0 @@
-package press.mantra.compose.ui.view.state
-
-import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.nostr.GroupRelayList
-
-sealed interface GroupRelaysUIState {
- data class Loaded(
- val localChatRoom: LocalChatRoom,
- /**
- * Where the group has said it lives on nostr, one entry per relay set.
- *
- * Empty only in a room that has none of this to say. Every other case
- * has four entries, some of them unsigned; see
- * `ChatRepository.groupRelayLists`.
- */
- val groupRelayLists: List = emptyList(),
- /**
- * Whether this device holds a share of the group's key.
- *
- * False leaves the lists readable and hides the way into the editor,
- * for the reason `GroupNostrProfileUIState.Loaded.canSign` gives.
- */
- val canSign: Boolean = false,
- ): GroupRelaysUIState
-
- data class Error(
- val message: String
- ): GroupRelaysUIState
-
- data object Loading: GroupRelaysUIState
-}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProposeGroupEventUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProposeGroupEventUIState.kt
deleted file mode 100644
index 81a2b502..00000000
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProposeGroupEventUIState.kt
+++ /dev/null
@@ -1,32 +0,0 @@
-package press.mantra.compose.ui.view.state
-
-import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.nostr.GroupNostrProfile
-
-sealed interface ProposeGroupEventUIState {
- data class Loaded(
- val localChatRoom: LocalChatRoom,
- /**
- * Who the group says it is, for the byline over the paste.
- *
- * Whatever is pasted goes out signed by the group and read as the group,
- * so the screen shows whose name it will carry -- which is not the
- * member pasting it, and not whatever `pubkey` the paste may name.
- */
- val groupNostrProfile: GroupNostrProfile? = null,
- /**
- * Whether this device holds a share of the group's key.
- *
- * False leaves the paste writable and the button inert, for the same
- * reason the typed editors do: reading and checking cost nothing, and
- * only a share-holder can open the session that would sign it.
- */
- val canSign: Boolean = false,
- ): ProposeGroupEventUIState
-
- data class Error(
- val message: String
- ): ProposeGroupEventUIState
-
- data object Loading: ProposeGroupEventUIState
-}
diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/CuratedSuggestionTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/CuratedSuggestionTest.kt
deleted file mode 100644
index 3cd150ca..00000000
--- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/CuratedSuggestionTest.kt
+++ /dev/null
@@ -1,191 +0,0 @@
-package press.mantra.compose.nostr
-
-import press.mantra.compose.database.GENESIS_AT
-import press.mantra.compose.database.model.GroupSignedEvent
-import press.mantra.compose.database.model.NostrEvent
-import press.mantra.compose.database.model.Profile
-import press.mantra.compose.database.model.intermdiate.LocalNostrEvent
-import press.mantra.compose.nostr.curated.CuratedField
-import press.mantra.compose.nostr.curated.CuratedFieldConfig
-import press.mantra.compose.nostr.curated.CuratedFieldType
-import press.mantra.compose.nostr.curated.CuratedSchema
-import press.mantra.compose.nostr.curated.CuratedSchemaEvent
-import press.mantra.compose.nostr.curated.CuratedVisibility
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertFalse
-import kotlin.test.assertTrue
-import kotlin.time.Instant
-
-/**
- * The queue of a list the group curates, read off whatever this device has
- * stored for the coordinate.
- *
- * The rows come from a filter on kind and `a` tag, which is broader than the
- * queue in three ways this reading has to close: an event that does not satisfy
- * the schema, a kind 31890 signed by somebody other than the group, and an
- * older version of an entry its author has since replaced. And one thing it
- * has to add: whether the group has already taken a suggestion up, which is a
- * canonical entry sharing its `d`.
- *
- * Also what to call a stranger. A profile that has arrived is a name; the
- * placeholder the indexer writes on first sight is not, and a queue full of
- * "LOADING..." would be a queue of nobody.
- */
-class CuratedSuggestionTest {
- private val chatRoomId = "d4c3b2a1".repeat(8)
- private val ada = "a".repeat(64)
- private val bob = "b".repeat(64)
-
- private val films = GroupCuratedSchema(
- signedEvent = GroupSignedEvent(
- id = "c".repeat(64),
- chatRoomId = chatRoomId,
- publicKey = chatRoomId,
- kind = CuratedSchemaEvent.KIND,
- tags = emptyArray(),
- content = "",
- signature = "f".repeat(128),
- createdAt = Instant.fromEpochSeconds(1_700_000_000),
- ),
- schema = CuratedSchema(
- identifier = "films",
- title = "Film suggestion",
- name = "Films worth translating",
- description = "Films the group would subtitle.",
- visibility = CuratedVisibility.Public,
- fields = listOf(
- CuratedField.IDENTIFIER,
- CuratedField.TITLE,
- CuratedField(
- "watchUrl",
- CuratedFieldType.Url,
- isRequired = true,
- config = CuratedFieldConfig(tag = "r", marker = "watch")
- ),
- ),
- ).normalized(),
- )
-
- private val coordinate = films.coordinate()
-
- @Test
- fun `the queue is every suggestion that satisfies the schema, newest first`() {
- val queue = CuratedSuggestion.queueOf(
- films,
- listOf(
- stored(suggestion(ada, "older", "The older one", 1_700_000_000)),
- stored(suggestion(bob, "newer", "The newer one", 1_700_000_900)),
- // No watch link, which the schema requires: not shown with the
- // gap blanked out, not shown at all.
- stored(suggestion(ada, "broken", "The broken one", 1_700_000_500, watchUrl = null)),
- // Another list's reply with this one's identifier. Not this queue.
- stored(suggestion(ada, "elsewhere", "Somebody else's", 1_700_000_600, root = "31889:${"e".repeat(64)}:films")),
- // A text note that happens to carry the coordinate.
- stored(suggestion(ada, "note", "A note", 1_700_000_700).copy(kind = 1)),
- )
- )
-
- assertEquals(listOf("The newer one", "The older one"), queue.map { it.entry.title })
- assertTrue(queue.none { it.isCurated })
- }
-
- @Test
- fun `an entry its author has replaced shows once, as the newer version`() {
- val queue = CuratedSuggestion.queueOf(
- films,
- listOf(
- stored(suggestion(ada, "rise", "The Rise and Rise of Bitcoin", 1_700_000_000)),
- stored(suggestion(ada, "rise", "The Rise and Rise of Bitcoin (2014)", 1_700_000_900)),
- // The same `d` under another key is another entry: coordinates
- // carry the author.
- stored(suggestion(bob, "rise", "Bob's rise", 1_700_000_100)),
- )
- )
-
- assertEquals(
- listOf("The Rise and Rise of Bitcoin (2014)", "Bob's rise"),
- queue.map { it.entry.title }
- )
- }
-
- @Test
- fun `a suggestion the group has taken up is marked, and the group's entry is not in the queue`() {
- val queue = CuratedSuggestion.queueOf(
- films,
- listOf(
- stored(suggestion(ada, "rise", "The Rise and Rise of Bitcoin", 1_700_000_000)),
- stored(suggestion(bob, "magic", "Magic Money", 1_700_000_100)),
- stored(canonical(chatRoomId, "rise", "The Rise and Rise of Bitcoin", 1_700_000_900)),
- // A kind 31890 by somebody who is not the curator is nobody's
- // list, and marks nothing.
- stored(canonical(bob, "magic", "Magic Money", 1_700_000_950)),
- )
- )
-
- assertEquals(listOf("Magic Money", "The Rise and Rise of Bitcoin"), queue.map { it.entry.title })
- assertEquals(
- mapOf("Magic Money" to false, "The Rise and Rise of Bitcoin" to true),
- queue.associate { it.entry.title to it.isCurated }
- )
- }
-
- @Test
- fun `a suggester is named by their profile, and by their key until one arrives`() {
- val named = Profile(
- publicKey = ada,
- displayName = "Ada",
- nostrEventId = "e".repeat(64),
- createdAt = Instant.fromEpochSeconds(1_700_000_000),
- )
- val placeholder = Profile(
- publicKey = bob,
- displayName = "LOADING...",
- nostrEventId = "e".repeat(64),
- createdAt = GENESIS_AT,
- )
-
- val queue = CuratedSuggestion.queueOf(
- films,
- listOf(
- stored(suggestion(ada, "one", "One", 1_700_000_000), profile = named),
- stored(suggestion(bob, "two", "Two", 1_700_000_100), profile = placeholder),
- stored(suggestion("c".repeat(64), "three", "Three", 1_700_000_200), profile = null),
- )
- )
-
- assertEquals(
- listOf("cccccccc", "bbbbbbbb", "Ada"),
- queue.map { it.suggesterName() }
- )
- assertFalse(queue.any { it.suggesterName() == "LOADING..." })
- }
-
- private fun suggestion(
- pubKey: String,
- identifier: String,
- title: String,
- createdAt: Long,
- watchUrl: String? = "https://example.com/watch",
- root: String = coordinate,
- ) = NostrEvent(
- id = createdAt.toString().padStart(60, '0') + pubKey.take(4),
- pubKey = pubKey,
- kind = CuratedSchemaEvent.SUGGESTION_KIND,
- tags = listOfNotNull(
- arrayOf("d", identifier),
- arrayOf("title", title),
- watchUrl?.let { arrayOf("r", it, "watch") },
- arrayOf("a", root, "", "root"),
- ).toTypedArray(),
- content = "",
- sig = "b".repeat(128),
- createdAt = Instant.fromEpochSeconds(createdAt),
- )
-
- private fun canonical(pubKey: String, identifier: String, title: String, createdAt: Long) =
- suggestion(pubKey, identifier, title, createdAt).copy(kind = CuratedSchemaEvent.CANONICAL_KIND)
-
- private fun stored(event: NostrEvent, profile: Profile? = null) =
- LocalNostrEvent(nostrEvent = event, profile = profile)
-}
diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupCuratedEntryTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupCuratedEntryTest.kt
deleted file mode 100644
index e65a72db..00000000
--- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupCuratedEntryTest.kt
+++ /dev/null
@@ -1,317 +0,0 @@
-package press.mantra.compose.nostr
-
-import com.vitorpamplona.quartz.nip01Core.core.Event
-import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
-import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
-import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
-import fr.acinq.bitcoin.ByteVector
-import fr.acinq.bitcoin.ByteVector32
-import fr.acinq.bitcoin.PrivateKey
-import fr.acinq.bitcoin.crypto.frost.Frost
-import fr.acinq.bitcoin.crypto.frost.IndividualNonce
-import fr.acinq.bitcoin.crypto.frost.KeyMaterial
-import fr.acinq.bitcoin.crypto.frost.SecretNonce
-import fr.acinq.bitcoin.crypto.frost.Session
-import press.mantra.compose.database.model.GroupSignedEvent
-import press.mantra.compose.extensions.toHex
-import press.mantra.compose.managers.SharedKeyDerivation
-import press.mantra.compose.nostr.curated.CuratedEntryEvent
-import press.mantra.compose.nostr.curated.CuratedField
-import press.mantra.compose.nostr.curated.CuratedFieldConfig
-import press.mantra.compose.nostr.curated.CuratedFieldType
-import press.mantra.compose.nostr.curated.CuratedSchema
-import press.mantra.compose.nostr.curated.CuratedSchemaEvent
-import press.mantra.compose.nostr.curated.CuratedSuggestionRef
-import press.mantra.compose.nostr.curated.CuratedVisibility
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertNotNull
-import kotlin.test.assertNull
-
-/**
- * What may be shown as an entry the group accepted into a list it curates.
- *
- * The reading gate is `GroupCuratedSchemaTest`'s -- the room has to be the
- * author and the signature has to be the room's -- plus the two things this
- * reader alone decides. An entry is read against the list it replies to, so
- * one replying to a list this device holds no schema for is not shown, and one
- * the list's own fields refuse is not shown either: it is the check the queue
- * makes on a relay's copy, made here on the group's own, so the two screens
- * cannot disagree about what the group accepted.
- *
- * The ordering half is the schema's: one per coordinate, newest winning, since
- * re-curating is a newer event under the same `d`.
- */
-class GroupCuratedEntryTest {
- private val participants = 3
- private val threshold = 2
-
- private val groupMaterial: KeyMaterial = Frost.trustedDealerKeygen(
- thresholdSecretKey = PrivateKey(
- ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1")
- ),
- nParticipants = participants,
- threshold = threshold
- )
-
- /** Another group entirely, for the entries signed by the wrong room. */
- private val strangerMaterial: KeyMaterial = Frost.trustedDealerKeygen(
- thresholdSecretKey = PrivateKey(
- ByteVector32("3decade0000000000000000000000000000000000000000000000000000000c3")
- ),
- nParticipants = participants,
- threshold = threshold
- )
-
- private val path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
-
- private val chatRoomId =
- SharedKeyDerivation.marmotGroupId(groupMaterial.thresholdPublicKey.value.toHex(), path)
-
- private val suggester = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860"
-
- private val films = CuratedSchema(
- identifier = "films",
- title = "Film suggestion",
- name = "Films worth translating",
- description = "Films the group would subtitle, with a link to watch each one.",
- visibility = CuratedVisibility.Public,
- fields = listOf(
- CuratedField.IDENTIFIER,
- CuratedField.TITLE,
- CuratedField("year", CuratedFieldType.Year, isRequired = false, label = "Year", config = CuratedFieldConfig(min = 1900, max = 2100)),
- CuratedField(
- name = "watchUrl",
- type = CuratedFieldType.Url,
- isRequired = true,
- label = "Where to watch",
- config = CuratedFieldConfig(tag = "r", marker = "watch", max = 500, https = true),
- ),
- ),
- relays = listOf("wss://relay.example.com"),
- )
-
- private val books = films.copy(identifier = "books", name = "Books worth translating")
-
- /** The lists as the group's screen holds them: read off the group's real signatures. */
- private val schemas: List = listOf(films, books).map { schema ->
- assertNotNull(GroupCuratedSchema.of(signed(CuratedSchemaEvent.template(schema, createdAt = 1_700_000_000)), chatRoomId))
- }
-
- private val filmsList = schemas.first { it.identifier == "films" }
- private val booksList = schemas.first { it.identifier == "books" }
-
- private fun riseAndRise(year: String = "2014") = mapOf(
- "identifier" to listOf("imdb:tt2821314"),
- "title" to listOf("The Rise and Rise of Bitcoin"),
- "year" to listOf(year),
- "watchUrl" to listOf("https://bitcoindoc.com/"),
- )
-
- private val source = CuratedSuggestionRef(
- id = "a".repeat(64),
- coordinate = "31888:$suggester:imdb:tt2821314",
- pubKey = suggester,
- )
-
- private fun entry(
- list: GroupCuratedSchema = filmsList,
- values: Map> = riseAndRise(),
- createdAt: Long = 1_700_000_900,
- ): EventTemplate<*> = CuratedEntryEvent.canonicalTemplate(
- schema = list.schema,
- coordinate = list.coordinate(),
- curator = list.publicKey,
- values = values,
- source = source,
- createdAt = createdAt,
- )
-
- @Test
- fun `an entry the room signed into a list it curates is one it accepted`() {
- val accepted = assertNotNull(GroupCuratedEntry.of(signed(entry()), chatRoomId, schemas))
-
- assertEquals("The Rise and Rise of Bitcoin", accepted.title)
- assertEquals("imdb:tt2821314", accepted.identifier)
- assertEquals("31890:$chatRoomId:imdb:tt2821314", accepted.coordinate())
- assertEquals(filmsList, accepted.list)
- assertEquals("2014", accepted.entry.value("year"))
- assertEquals(source, accepted.entry.source)
- }
-
- @Test
- fun `an entry another group signed is not this group's`() {
- val stranger = GroupSignedEvent.fromEvent(
- event = event(entry(), material = strangerMaterial),
- chatRoomId = chatRoomId
- )
-
- assertNull(GroupCuratedEntry.of(stranger, chatRoomId, schemas))
- }
-
- @Test
- fun `an entry the room did not sign is not one`() {
- // Authored by the room and signed by somebody else: a member declaring
- // the group accepted something, which the reading verifies rather than
- // trusting the author field.
- assertNull(GroupCuratedEntry.of(signed(entry(), signer = strangerMaterial), chatRoomId, schemas))
-
- listOf("f".repeat(128), "not a signature", "").forEach { rubbish ->
- assertNull(
- GroupCuratedEntry.of(signed(entry(), signature = rubbish), chatRoomId, schemas),
- "an entry signed with \"$rubbish\" was accepted"
- )
- }
- }
-
- @Test
- fun `an event of another kind is not an entry`() {
- assertNull(
- GroupCuratedEntry.of(signed(entry()).copy(kind = CuratedEntryEvent.SUGGESTION_KIND), chatRoomId, schemas)
- )
- }
-
- @Test
- fun `an entry into a list this device holds no schema for is not shown`() {
- // Really signed, and replying to a real list -- just not one among the
- // schemas the reader was given. There are no fields to read it by.
- assertNull(GroupCuratedEntry.of(signed(entry(list = booksList)), chatRoomId, schemas = listOf(filmsList)))
- }
-
- @Test
- fun `an entry the list's own fields refuse is not shown`() {
- // The room's real signature over a year outside the schema's range: the
- // group's statement, and still not an entry the list's readers would
- // count. The queue would drop the relay's copy for the same reason.
- val outOfRange = signed(entry(values = riseAndRise(year = "1850")))
-
- assertNull(GroupCuratedEntry.of(outOfRange, chatRoomId, schemas))
- assertEquals(emptyList(), GroupCuratedEntry.newestPerEntryAmong(listOf(outOfRange), chatRoomId, schemas))
- }
-
- @Test
- fun `the newest per coordinate wins, and two identifiers are two entries`() {
- val first = signed(entry(createdAt = 1_700_000_900))
- val revised = signed(entry(values = riseAndRise(year = "2015"), createdAt = 1_700_001_800))
- val another = signed(
- entry(
- values = mapOf(
- "identifier" to listOf("magic-money"),
- "title" to listOf("Magic Money"),
- "watchUrl" to listOf("https://example.com/watch"),
- ),
- createdAt = 1_700_001_200,
- )
- )
-
- listOf(
- listOf(first, revised, another),
- listOf(another, revised, first),
- listOf(revised, another, first),
- ).forEach { events ->
- val accepted = GroupCuratedEntry.newestPerEntryAmong(events, chatRoomId, schemas)
-
- assertEquals(
- listOf("imdb:tt2821314", "magic-money"),
- accepted.map { it.identifier },
- "the entries came back in the wrong order, or one of them went missing"
- )
- assertEquals("2015", accepted.first().entry.value("year"), "a re-curation did not replace the entry it revised")
- }
- }
-
- @Test
- fun `the same identifier in two lists is one coordinate, and the newer decides`() {
- val inFilms = signed(entry(list = filmsList, createdAt = 1_700_000_900))
- val inBooks = signed(entry(list = booksList, createdAt = 1_700_001_800))
-
- val accepted = GroupCuratedEntry.newestPerEntryAmong(listOf(inFilms, inBooks), chatRoomId, schemas)
-
- assertEquals(1, accepted.size, "one coordinate was shown as two entries")
- assertEquals(booksList, accepted.single().list)
- }
-
- /** An entry as `FrostSigningManager.complete` files one. */
- private fun signed(
- template: EventTemplate<*>,
- material: KeyMaterial = groupMaterial,
- signer: KeyMaterial = material,
- signature: String? = null
- ): GroupSignedEvent = GroupSignedEvent.fromEvent(
- event = event(template, material, signer, signature),
- chatRoomId = chatRoomId,
- derivationPath = SharedKeyDerivation.formatPath(path)
- )
-
- private fun event(
- template: EventTemplate<*>,
- material: KeyMaterial = groupMaterial,
- signer: KeyMaterial = material,
- signature: String? = null
- ): Event {
- val groupPubKey = SharedKeyDerivation
- .derive(material.thresholdPublicKey.value.toHex(), path)
- .hex
-
- val id = EventHasher.hashId(
- pubKey = groupPubKey,
- createdAt = template.createdAt,
- kind = template.kind,
- tags = template.tags,
- content = template.content
- )
-
- return Event(
- id = id,
- pubKey = groupPubKey,
- createdAt = template.createdAt,
- kind = template.kind,
- tags = template.tags,
- content = template.content,
- sig = signature ?: groupSignature(signer, id)
- )
- }
-
- /**
- * A real FROST signature by [material]'s quorum over [eventId], through the
- * same shape `FrostSigningManager.advance` runs.
- */
- private fun groupSignature(material: KeyMaterial, eventId: String): String {
- val cache = SharedKeyDerivation
- .derive(material.thresholdPublicKey.value.toHex(), path)
- .cache
- val message = ByteVector(eventId.hexToByteArray())
- val signerIds = listOf(0, 1)
-
- val nonces = signerIds.map { signerId ->
- SecretNonce.generate(
- sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"),
- secretShare = material.secretShares[signerId],
- publicShare = material.publicShares[signerId],
- tweakedThresholdPublicKey = cache.tweakedPublicKey,
- message = message,
- extraInput = null
- )
- }
-
- val signingSession = Session.create(
- aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!,
- signerIds = signerIds.map { it.toUInt() },
- signerPublicShares = signerIds.map { material.publicShares[it] },
- nParticipants = participants,
- threshold = threshold,
- tweakCache = cache,
- message = message
- )
-
- val partials = signerIds.mapIndexed { position, signerId ->
- signingSession.sign(
- nonces[position].first,
- material.secretShares[signerId],
- signerId.toUInt()
- ).right!!
- }
-
- return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex()
- }
-}
diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupCuratedSchemaTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupCuratedSchemaTest.kt
deleted file mode 100644
index 84751493..00000000
--- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupCuratedSchemaTest.kt
+++ /dev/null
@@ -1,317 +0,0 @@
-package press.mantra.compose.nostr
-
-import press.mantra.compose.database.model.GroupSignedEvent
-import press.mantra.compose.extensions.toHex
-import press.mantra.compose.managers.SharedKeyDerivation
-import press.mantra.compose.nostr.curated.CuratedField
-import press.mantra.compose.nostr.curated.CuratedFieldConfig
-import press.mantra.compose.nostr.curated.CuratedFieldType
-import press.mantra.compose.nostr.curated.CuratedSchema
-import press.mantra.compose.nostr.curated.CuratedSchemaEvent
-import press.mantra.compose.nostr.curated.CuratedVisibility
-import com.vitorpamplona.quartz.nip01Core.core.Event
-import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
-import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
-import fr.acinq.bitcoin.ByteVector
-import fr.acinq.bitcoin.ByteVector32
-import fr.acinq.bitcoin.PrivateKey
-import fr.acinq.bitcoin.crypto.frost.Frost
-import fr.acinq.bitcoin.crypto.frost.IndividualNonce
-import fr.acinq.bitcoin.crypto.frost.KeyMaterial
-import fr.acinq.bitcoin.crypto.frost.SecretNonce
-import fr.acinq.bitcoin.crypto.frost.Session
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertNotNull
-import kotlin.test.assertNull
-import kotlin.test.assertTrue
-
-/**
- * What may be shown as a list the group curates.
- *
- * The reading gate is `GroupPostTest`'s -- the room has to be the author and the
- * signature has to be the room's -- and it carries a weight of its own here: a
- * schema read wrong would have strangers filling in a form under a group's name
- * that the group never agreed to, and the group's key accepting entries against
- * it. The third refusal is this reader's alone: a schema the room really signed
- * but that no client could act on is not shown as one it curates.
- *
- * The ordering half is between the profile's and the posts': one per identifier,
- * newest winning within it, because the kind is addressable and a group may
- * curate several lists.
- */
-class GroupCuratedSchemaTest {
- private val participants = 3
- private val threshold = 2
-
- private val groupMaterial: KeyMaterial = Frost.trustedDealerKeygen(
- thresholdSecretKey = PrivateKey(
- ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1")
- ),
- nParticipants = participants,
- threshold = threshold
- )
-
- /** Another group entirely, for the schemas signed by the wrong room. */
- private val strangerMaterial: KeyMaterial = Frost.trustedDealerKeygen(
- thresholdSecretKey = PrivateKey(
- ByteVector32("3decade0000000000000000000000000000000000000000000000000000000c3")
- ),
- nParticipants = participants,
- threshold = threshold
- )
-
- private val path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
-
- private val chatRoomId =
- SharedKeyDerivation.marmotGroupId(groupMaterial.thresholdPublicKey.value.toHex(), path)
-
- private val films = CuratedSchema(
- identifier = "films",
- title = "Film suggestion",
- name = "Films worth translating",
- description = "Films the group would subtitle, with a link to watch each one.",
- visibility = CuratedVisibility.Public,
- fields = listOf(
- CuratedField.IDENTIFIER,
- CuratedField.TITLE,
- CuratedField(
- name = "watchUrl",
- type = CuratedFieldType.Url,
- isRequired = true,
- placeholder = "https://…",
- label = "Where to watch",
- config = CuratedFieldConfig(tag = "r", marker = "watch", max = 500, https = true),
- ),
- ),
- relays = listOf("wss://relay.example.com"),
- )
-
- @Test
- fun `a schema the room signed is a list the group curates`() {
- val curated = assertNotNull(
- GroupCuratedSchema.of(signedEvent = signed(films), chatRoomId = chatRoomId)
- )
-
- assertEquals("films", curated.identifier)
- assertEquals(chatRoomId, curated.publicKey)
- assertEquals("31889:$chatRoomId:films", curated.coordinate())
- assertEquals(films, curated.schema)
- assertEquals("Films worth translating", curated.schema.displayName())
- }
-
- @Test
- fun `a schema another group signed is not this group's`() {
- // A real quorum and a real signature by a group with no standing to
- // curate for this one. The row names this room because that is where it
- // was filed; the author is what decides whose list it is.
- val stranger = GroupSignedEvent.fromEvent(
- event = schemaEvent(films, material = strangerMaterial),
- chatRoomId = chatRoomId
- )
-
- assertNull(GroupCuratedSchema.of(signedEvent = stranger, chatRoomId = chatRoomId))
- }
-
- @Test
- fun `a schema the room did not sign is not one`() {
- // Authored by the room and signed by somebody else: the shape a member
- // declaring a list in the group's name would produce, and the reason the
- // reading verifies rather than trusting the author field.
- assertNull(
- GroupCuratedSchema.of(
- signedEvent = signed(films, signer = strangerMaterial),
- chatRoomId = chatRoomId
- )
- )
-
- listOf("f".repeat(128), "not a signature", "").forEach { rubbish ->
- assertNull(
- GroupCuratedSchema.of(
- signedEvent = signed(films, signature = rubbish),
- chatRoomId = chatRoomId
- ),
- "a schema signed with \"$rubbish\" was accepted"
- )
- }
- }
-
- @Test
- fun `an event of another kind is not a schema`() {
- assertNull(
- GroupCuratedSchema.of(
- signedEvent = signed(films).copy(kind = CuratedSchemaEvent.SUGGESTION_KIND),
- chatRoomId = chatRoomId
- )
- )
- }
-
- @Test
- fun `a schema the room signed but no client could act on is refused`() {
- // The room's real signature over a kind 31889 with no visibility. It is
- // the group's statement and it is still not a list: a reader that showed
- // it would be showing a form nobody may be allowed to fill in.
- val unusable = signed(
- films,
- tags = CuratedSchemaEvent.template(films, createdAt = 1_700_000_000).tags
- .filterNot { it[0] == "visibility" }
- .toTypedArray()
- )
-
- assertNull(GroupCuratedSchema.of(signedEvent = unusable, chatRoomId = chatRoomId))
- assertEquals(emptyList(), GroupCuratedSchema.newestPerListAmong(listOf(unusable), chatRoomId))
- }
-
- @Test
- fun `the newest per identifier wins, and two identifiers are two lists`() {
- val firstFilms = signed(films, createdAt = 1_700_000_000)
- val editedFilms = signed(films.copy(description = "Films the group would subtitle."), createdAt = 1_700_000_900)
- val books = signed(films.copy(identifier = "books", name = "Books worth translating"), createdAt = 1_700_000_300)
-
- listOf(
- listOf(firstFilms, editedFilms, books),
- listOf(books, editedFilms, firstFilms),
- listOf(editedFilms, books, firstFilms),
- ).forEach { events ->
- val lists = GroupCuratedSchema.newestPerListAmong(events, chatRoomId)
-
- assertEquals(
- listOf("films", "books"),
- lists.map { it.identifier },
- "the lists came back in the wrong order, or one of them went missing"
- )
- assertEquals(
- "Films the group would subtitle.",
- lists.first().schema.description,
- "an edit did not replace the schema it revised"
- )
- }
- }
-
- @Test
- fun `two versions signed in the same second resolve the same way on every device`() {
- val one = signed(films.copy(description = "One."), createdAt = 1_700_000_000)
- val other = signed(films.copy(description = "Other."), createdAt = 1_700_000_000)
-
- val expected = GroupCuratedSchema.newestPerListAmong(listOf(one, other), chatRoomId).single()
-
- assertEquals(
- expected,
- GroupCuratedSchema.newestPerListAmong(listOf(other, one), chatRoomId).single(),
- "a tie on the timestamp was broken differently by the two orderings"
- )
- assertEquals(
- if (one.id > other.id) "One." else "Other.",
- expected.schema.description,
- "the tie should break on the id, which is the only thing both devices share"
- )
- }
-
- @Test
- fun `a schema the editor built reads back as the group defined it`() {
- val template = GroupCuratedSchema.template(films)
-
- assertEquals(CuratedSchemaEvent.KIND, template.kind)
- assertEquals(films.description, template.content)
-
- val curated = assertNotNull(
- GroupCuratedSchema.of(
- signedEvent = signed(films, tags = template.tags, content = template.content),
- chatRoomId = chatRoomId
- )
- )
-
- assertEquals(films, curated.schema)
- assertTrue(curated.schema.fields.first { it.name == "watchUrl" }.isRequired)
- }
-
- /** A schema as `FrostSigningManager.complete` files one. */
- private fun signed(
- schema: CuratedSchema,
- tags: Array> = CuratedSchemaEvent.template(schema, createdAt = 1_700_000_000).tags,
- content: String = schema.description,
- createdAt: Long = 1_700_000_000,
- material: KeyMaterial = groupMaterial,
- signer: KeyMaterial = material,
- signature: String? = null
- ): GroupSignedEvent = GroupSignedEvent.fromEvent(
- event = schemaEvent(schema, tags, content, createdAt, material, signer, signature),
- chatRoomId = chatRoomId,
- derivationPath = SharedKeyDerivation.formatPath(path)
- )
-
- private fun schemaEvent(
- schema: CuratedSchema,
- tags: Array> = CuratedSchemaEvent.template(schema, createdAt = 1_700_000_000).tags,
- content: String = schema.description,
- createdAt: Long = 1_700_000_000,
- material: KeyMaterial = groupMaterial,
- signer: KeyMaterial = material,
- signature: String? = null
- ): Event {
- val groupPubKey = SharedKeyDerivation
- .derive(material.thresholdPublicKey.value.toHex(), path)
- .hex
-
- val id = EventHasher.hashId(
- pubKey = groupPubKey,
- createdAt = createdAt,
- kind = CuratedSchemaEvent.KIND,
- tags = tags,
- content = content
- )
-
- return Event(
- id = id,
- pubKey = groupPubKey,
- createdAt = createdAt,
- kind = CuratedSchemaEvent.KIND,
- tags = tags,
- content = content,
- sig = signature ?: groupSignature(signer, id)
- )
- }
-
- /**
- * A real FROST signature by [material]'s quorum over [eventId], through the
- * same shape `FrostSigningManager.advance` runs.
- */
- private fun groupSignature(material: KeyMaterial, eventId: String): String {
- val cache = SharedKeyDerivation
- .derive(material.thresholdPublicKey.value.toHex(), path)
- .cache
- val message = ByteVector(eventId.hexToByteArray())
- val signerIds = listOf(0, 1)
-
- val nonces = signerIds.map { signerId ->
- SecretNonce.generate(
- sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"),
- secretShare = material.secretShares[signerId],
- publicShare = material.publicShares[signerId],
- tweakedThresholdPublicKey = cache.tweakedPublicKey,
- message = message,
- extraInput = null
- )
- }
-
- val signingSession = Session.create(
- aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!,
- signerIds = signerIds.map { it.toUInt() },
- signerPublicShares = signerIds.map { material.publicShares[it] },
- nParticipants = participants,
- threshold = threshold,
- tweakCache = cache,
- message = message
- )
-
- val partials = signerIds.mapIndexed { position, signerId ->
- signingSession.sign(
- nonces[position].first,
- material.secretShares[signerId],
- signerId.toUInt()
- ).right!!
- }
-
- return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex()
- }
-}
diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupEventProposalTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupEventProposalTest.kt
deleted file mode 100644
index a9beafd9..00000000
--- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupEventProposalTest.kt
+++ /dev/null
@@ -1,338 +0,0 @@
-package press.mantra.compose.nostr
-
-import press.mantra.compose.database.model.GroupSignedEvent
-import press.mantra.compose.extensions.toHex
-import press.mantra.compose.managers.SharedKeyDerivation
-import press.mantra.compose.nostr.curated.CuratedSchemaEvent
-import press.mantra.compose.nostr.curated.CuratedSchemaProblem
-import press.mantra.compose.nostr.curated.CuratedVisibility
-import com.vitorpamplona.quartz.nip01Core.core.Event
-import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
-import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
-import fr.acinq.bitcoin.ByteVector
-import fr.acinq.bitcoin.ByteVector32
-import fr.acinq.bitcoin.PrivateKey
-import fr.acinq.bitcoin.crypto.frost.Frost
-import fr.acinq.bitcoin.crypto.frost.IndividualNonce
-import fr.acinq.bitcoin.crypto.frost.KeyMaterial
-import fr.acinq.bitcoin.crypto.frost.SecretNonce
-import fr.acinq.bitcoin.crypto.frost.Session
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertIs
-import kotlin.test.assertNotNull
-import kotlin.test.assertTrue
-
-/**
- * What a pasted event may become, and where it goes once the group has signed it.
- *
- * Two halves. The first is the reading: which pastes are refused and why, and
- * that what is refused is exactly what a reader would refuse -- the point of
- * checking before proposing is that a quorum is never asked to sign something
- * that will be filed and shown nowhere.
- *
- * The second is the promise the feature makes: a pasted kind 0 becomes the
- * profile, a kind 1 a post, a kind 31889 a schema, a relay list a relay list. That
- * is not this module's doing -- `FrostSigningManager.complete` files every signed
- * event and the group's screen reads each section off those by kind -- so the
- * test is the whole chain: the paste read here, signed by a real quorum through
- * the same shape `FrostSigningManager.advance` runs, and read back by the reader
- * that section uses.
- */
-class GroupEventProposalTest {
- private val participants = 3
- private val threshold = 2
-
- private val groupMaterial: KeyMaterial = Frost.trustedDealerKeygen(
- thresholdSecretKey = PrivateKey(
- ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1")
- ),
- nParticipants = participants,
- threshold = threshold
- )
-
- private val path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
-
- private val chatRoomId =
- SharedKeyDerivation.marmotGroupId(groupMaterial.thresholdPublicKey.value.toHex(), path)
-
- /**
- * The bitcoin.mov schema as `npm run schema:dry` would print it: a whole
- * event, id, author, signature and time included, abbreviated to the tags
- * a reading depends on.
- */
- private val pastedSchema = """
- {
- "id": "b2f8c7a7b4d5e6f70123456789abcdef0123456789abcdef0123456789abcdef",
- "pubkey": "7c965d8c2acdfd635562da3bcb82596b595be28b008d8b54ec702ed4c67d9d25",
- "created_at": 1735689600,
- "kind": 31889,
- "tags": [
- ["d", "bitcoin.mov"],
- ["title", "bitcoin.mov suggestion"],
- ["name", "bitcoin.mov"],
- ["description", "Fields for a bitcoin.mov entry."],
- ["k", "31888"],
- ["visibility", "public"],
- ["domain", "bitcoin.mov"],
- ["relay", "wss://ephemeral.mantra.press"],
- ["field", "identifier", "token", "required", "", "Identifier", "{\"tag\":\"d\",\"max\":80,\"derived\":true}"],
- ["field", "title", "text", "required", "The Rise and Rise of Bitcoin", "Title", "{\"max\":200}"],
- ["field", "watchUrl", "url", "optional", "", "Watch / reference URL", "{\"tag\":\"r\",\"marker\":\"watch\",\"repeat\":true}"]
- ],
- "content": "Fields for a bitcoin.mov entry.",
- "sig": "c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00"
- }
- """.trimIndent()
-
- @Test
- fun `a pasted event is read as its kind, tags and content, and nothing else`() {
- val ready = assertIs(GroupEventProposal.read(pastedSchema))
-
- assertEquals(31889, ready.kind)
- assertEquals("Fields for a bitcoin.mov entry.", ready.content)
- assertEquals(11, ready.tags.size)
- assertEquals(listOf("d", "bitcoin.mov"), ready.tags.first().toList())
- // The config blob comes through verbatim, escapes and all.
- assertEquals("{\"tag\":\"d\",\"max\":80,\"derived\":true}", ready.tags[8][6])
-
- // The id, author, signature and time are not the paste's to decide, and
- // there is nowhere on a reading for them to go: the session hashes the id
- // over the room's key and the time it opens at.
- val preview = ready.preview()
- assertEquals("", preview.pubKey)
- assertEquals(0L, preview.createdAt)
- }
-
- @Test
- fun `a bare template with no tags or content still reads`() {
- val ready = assertIs(
- GroupEventProposal.read("""{"kind": 0, "content": "{}"}""")
- )
- assertEquals(0, ready.kind)
- assertEquals(0, ready.tags.size)
-
- // A numeric string for the kind is what a hand types; a relay writes a number.
- assertIs(
- GroupEventProposal.read("""{"kind": "1", "content": "Hello"}""")
- )
- }
-
- @Test
- fun `what is not an event is refused and named`() {
- assertEquals(GroupEventProposal.Refused.NotJson, GroupEventProposal.read("not json at all"))
- assertEquals(GroupEventProposal.Refused.NotJson, GroupEventProposal.read("{\"kind\": 1,"))
- assertEquals(GroupEventProposal.Refused.NotAnObject, GroupEventProposal.read("[1, 2, 3]"))
- assertEquals(GroupEventProposal.Refused.NotAnObject, GroupEventProposal.read("\"a string\""))
- assertEquals(GroupEventProposal.Refused.NoKind, GroupEventProposal.read("""{"content": "Hello"}"""))
- assertEquals(GroupEventProposal.Refused.NoKind, GroupEventProposal.read("""{"kind": "one"}"""))
- assertEquals(
- GroupEventProposal.Refused.TagsMalformed,
- GroupEventProposal.read("""{"kind": 1, "tags": "e", "content": "Hello"}""")
- )
- assertEquals(
- GroupEventProposal.Refused.TagsMalformed,
- GroupEventProposal.read("""{"kind": 1, "tags": [["e", 1]], "content": "Hello"}""")
- )
- assertEquals(
- GroupEventProposal.Refused.ContentNotText,
- GroupEventProposal.read("""{"kind": 1, "content": {"note": "Hello"}}""")
- )
- }
-
- @Test
- fun `a kind the group's screen has no place for is refused, whatever else it says`() {
- // A long-form article is a perfectly good nostr event and the group could
- // sign it. It would then be filed and shown nowhere, with nothing to tell
- // the member their quorum was spent on it.
- assertEquals(
- GroupEventProposal.Refused.KindNotAccepted(30023),
- GroupEventProposal.read("""{"kind": 30023, "tags": [["d", "x"]], "content": "An article"}""")
- )
-
- // The kinds with a section: the profile, the posts, the four relay lists
- // and the schemas. A subgroup certificate is deliberately not among them.
- assertEquals(
- setOf(0, 1, 10002, 10050, 10007, 10006, 31889),
- GroupEventProposal.ACCEPTED_KINDS
- )
- }
-
- @Test
- fun `a kind zero whose content is not a profile is refused, and an empty one is not`() {
- assertEquals(
- GroupEventProposal.Refused.ProfileUnreadable,
- GroupEventProposal.read("""{"kind": 0, "content": "not a profile"}""")
- )
-
- // Wiping a profile is a thing groups are entitled to do -- the same line
- // `GroupNostrProfile.of` draws.
- assertIs(GroupEventProposal.read("""{"kind": 0, "content": ""}"""))
- assertIs(
- GroupEventProposal.read("""{"kind": 0, "content": "{\"name\":\"Translation collective\"}"}""")
- )
- }
-
- @Test
- fun `a blank post is refused, the way the composer refuses one`() {
- listOf("", " ", "\\n\\n").forEach { blank ->
- assertEquals(
- GroupEventProposal.Refused.PostBlank,
- GroupEventProposal.read("""{"kind": 1, "content": "$blank"}"""),
- "a post saying \"$blank\" was accepted"
- )
- }
- }
-
- @Test
- fun `a schema a reader would refuse is refused here, with the reader's reasons`() {
- val withoutVisibility = pastedSchema.replace("""["visibility", "public"],""", "")
-
- assertEquals(
- GroupEventProposal.Refused.SchemaUnusable(listOf(CuratedSchemaProblem.VisibilityMissing)),
- GroupEventProposal.read(withoutVisibility)
- )
- }
-
- @Test
- fun `a relay list reads whether or not it names a relay`() {
- // An empty one is a list the group is withdrawing, which the relays card
- // shows as "no relays" rather than as nothing.
- assertIs(GroupEventProposal.read("""{"kind": 10002}"""))
- assertIs(
- GroupEventProposal.read("""{"kind": 10002, "tags": [["r", "wss://relay.example.com", "write"]]}""")
- )
- }
-
- @Test
- fun `a pasted kind zero, once signed, is the group's profile`() {
- val ready = assertIs(
- GroupEventProposal.read(
- """{"kind": 0, "pubkey": "not the group", "content": "{\"name\":\"Translation collective\",\"about\":\"We translate hard books.\"}"}"""
- )
- )
-
- val profile = assertNotNull(GroupNostrProfile.of(signed(ready), chatRoomId))
-
- assertEquals("Translation collective", profile.name())
- assertEquals("We translate hard books.", profile.about())
- assertEquals(chatRoomId, profile.publicKey)
- }
-
- @Test
- fun `a pasted kind one, once signed, is one of the group's posts`() {
- val ready = assertIs(
- GroupEventProposal.read("""{"kind": 1, "tags": [], "content": "We have finished the first chapter."}""")
- )
-
- val posts = GroupPost.newestFirstAmong(listOf(signed(ready)), chatRoomId)
-
- assertEquals(listOf("We have finished the first chapter."), posts.map { it.content })
- assertTrue(posts.single().isNewThread())
- }
-
- @Test
- fun `a pasted schema, once signed, is a list the group curates`() {
- val ready = assertIs(GroupEventProposal.read(pastedSchema))
-
- val curated = assertNotNull(GroupCuratedSchema.of(signed(ready), chatRoomId))
-
- assertEquals("bitcoin.mov", curated.identifier)
- assertEquals("bitcoin.mov", curated.schema.displayName())
- assertEquals(CuratedVisibility.Public, curated.schema.visibility)
- assertEquals(listOf("identifier", "title", "watchUrl"), curated.schema.fields.map { it.name })
- // The coordinate is the group's, not the pasted pubkey's: the group is
- // the curator of what it signed.
- assertEquals("${CuratedSchemaEvent.KIND}:$chatRoomId:bitcoin.mov", curated.coordinate())
- }
-
- @Test
- fun `a pasted relay list, once signed, is one of the group's relay lists`() {
- val ready = assertIs(
- GroupEventProposal.read("""{"kind": 10002, "tags": [["r", "wss://relay.example.com", "write"]]}""")
- )
-
- val list = GroupRelayList.newestAmong(listOf(signed(ready)), chatRoomId, GroupRelaySet.General)
-
- assertTrue(list.isAgreed)
- assertEquals(listOf("relay.example.com"), list.relays.map { it.displayUrl() })
- assertEquals(listOf(false), list.relays.map { it.read })
- assertEquals(listOf(true), list.relays.map { it.write })
- }
-
- /**
- * A reading as `FrostSigningManager.complete` would file it: authored by the
- * room's key at its path, the id hashed over that, a real quorum's signature
- * on it, and the time the session opened at rather than anything pasted.
- */
- private fun signed(ready: GroupEventProposal.Ready, createdAt: Long = 1_700_000_000): GroupSignedEvent {
- val groupPubKey = SharedKeyDerivation
- .derive(groupMaterial.thresholdPublicKey.value.toHex(), path)
- .hex
-
- val id = EventHasher.hashId(
- pubKey = groupPubKey,
- createdAt = createdAt,
- kind = ready.kind,
- tags = ready.tags,
- content = ready.content
- )
-
- return GroupSignedEvent.fromEvent(
- event = Event(
- id = id,
- pubKey = groupPubKey,
- createdAt = createdAt,
- kind = ready.kind,
- tags = ready.tags,
- content = ready.content,
- sig = groupSignature(groupMaterial, id)
- ),
- chatRoomId = chatRoomId,
- derivationPath = SharedKeyDerivation.formatPath(path)
- )
- }
-
- /**
- * A real FROST signature by [material]'s quorum over [eventId], through the
- * same shape `FrostSigningManager.advance` runs.
- */
- private fun groupSignature(material: KeyMaterial, eventId: String): String {
- val cache = SharedKeyDerivation
- .derive(material.thresholdPublicKey.value.toHex(), path)
- .cache
- val message = ByteVector(eventId.hexToByteArray())
- val signerIds = listOf(0, 1)
-
- val nonces = signerIds.map { signerId ->
- SecretNonce.generate(
- sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"),
- secretShare = material.secretShares[signerId],
- publicShare = material.publicShares[signerId],
- tweakedThresholdPublicKey = cache.tweakedPublicKey,
- message = message,
- extraInput = null
- )
- }
-
- val signingSession = Session.create(
- aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!,
- signerIds = signerIds.map { it.toUInt() },
- signerPublicShares = signerIds.map { material.publicShares[it] },
- nParticipants = participants,
- threshold = threshold,
- tweakCache = cache,
- message = message
- )
-
- val partials = signerIds.mapIndexed { position, signerId ->
- signingSession.sign(
- nonces[position].first,
- material.secretShares[signerId],
- signerId.toUInt()
- ).right!!
- }
-
- return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex()
- }
-}
diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupNostrProfileTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupNostrProfileTest.kt
deleted file mode 100644
index 944e7199..00000000
--- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupNostrProfileTest.kt
+++ /dev/null
@@ -1,438 +0,0 @@
-package press.mantra.compose.nostr
-
-import press.mantra.compose.database.model.GroupSignedEvent
-import press.mantra.compose.extensions.toHex
-import press.mantra.compose.managers.SharedKeyDerivation
-import com.vitorpamplona.quartz.nip01Core.core.Event
-import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
-import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
-import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
-import fr.acinq.bitcoin.ByteVector
-import fr.acinq.bitcoin.ByteVector32
-import fr.acinq.bitcoin.PrivateKey
-import fr.acinq.bitcoin.crypto.frost.Frost
-import fr.acinq.bitcoin.crypto.frost.IndividualNonce
-import fr.acinq.bitcoin.crypto.frost.KeyMaterial
-import fr.acinq.bitcoin.crypto.frost.SecretNonce
-import fr.acinq.bitcoin.crypto.frost.Session
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertNotNull
-import kotlin.test.assertNull
-
-/**
- * What a group's nostr profile is allowed to be read out of.
- *
- * A room's id is the pubkey it signs as, so a kind:0 authored by that key is the
- * group describing itself and a kind:0 authored by anything else is not -- however
- * it came to be filed against the room. The reading is what the group detail screen
- * shows and what an edit is built on top of, so both halves are worth pinning:
- * which events become a profile, and what an edit does to the one before it.
- *
- * The edit half is the one with a silent failure behind it. `updateFromPast` is
- * used precisely so a field this app does not offer survives being edited, and
- * `createNew` in its place would compile, pass any test that only looked at the
- * fields the form knows about, and quietly wipe the rest of the group's profile
- * every time somebody fixed a typo in its name.
- */
-class GroupNostrProfileTest {
- private val participants = 3
- private val threshold = 2
-
- /** The group whose profile this is. */
- private val groupMaterial: KeyMaterial = Frost.trustedDealerKeygen(
- thresholdSecretKey = PrivateKey(
- ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1")
- ),
- nParticipants = participants,
- threshold = threshold
- )
-
- /** Another group entirely, for the profiles signed by the wrong room. */
- private val strangerMaterial: KeyMaterial = Frost.trustedDealerKeygen(
- thresholdSecretKey = PrivateKey(
- ByteVector32("3decade0000000000000000000000000000000000000000000000000000000c3")
- ),
- nParticipants = participants,
- threshold = threshold
- )
-
- private val path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
-
- private val chatRoomId =
- SharedKeyDerivation.marmotGroupId(groupMaterial.thresholdPublicKey.value.toHex(), path)
- private val strangerChatRoomId =
- SharedKeyDerivation.marmotGroupId(strangerMaterial.thresholdPublicKey.value.toHex(), path)
-
- @Test
- fun `a kind zero the room signed is the group's profile`() {
- val profile = GroupNostrProfile.of(
- signedEvent = signed(
- content = """{"name":"Translation collective","about":"We translate hard books.",""" +
- """"picture":"https://example.com/group.png","nip05":"group@example.com"}"""
- ),
- chatRoomId = chatRoomId
- )
-
- assertNotNull(profile)
- assertEquals("Translation collective", profile.name())
- assertEquals("We translate hard books.", profile.about())
- assertEquals("https://example.com/group.png", profile.picture())
- assertEquals("group@example.com", profile.nip05())
- assertEquals(chatRoomId, profile.publicKey)
- }
-
- @Test
- fun `display_name is what a name is read out of when both are set`() {
- // Every other nostr client prefers it, and `template` writes both so they
- // agree. A group whose two names disagree got them from somewhere else,
- // and the answer has to be the one the rest of nostr will give.
- val profile = GroupNostrProfile.of(
- signedEvent = signed(
- content = """{"name":"collective","display_name":"Translation collective"}"""
- ),
- chatRoomId = chatRoomId
- )
-
- assertEquals("Translation collective", profile?.name())
- }
-
- @Test
- fun `a kind zero another group signed is not this group's profile`() {
- // A real quorum and a real signature by a group with no standing to say
- // anything about this room. The row names this room because that is where
- // the event was filed; the author is what decides whose profile it is.
- val stranger = GroupSignedEvent.fromEvent(
- event = metadataEvent(
- content = """{"name":"Somebody else"}""",
- material = strangerMaterial
- ),
- chatRoomId = chatRoomId
- )
-
- assertEquals(strangerChatRoomId, stranger.publicKey, "the fixture signs as the stranger")
- assertNull(GroupNostrProfile.of(signedEvent = stranger, chatRoomId = chatRoomId))
- }
-
- @Test
- fun `a kind zero whose signature is not the room's is not a profile`() {
- // Authored by the room, saying the right things, and signed by a key that
- // is not the room's. This is the shape a member forging a rename produces.
- assertNull(
- GroupNostrProfile.of(
- signedEvent = signed(
- content = """{"name":"Renamed by one member"}""",
- signer = strangerMaterial
- ),
- chatRoomId = chatRoomId
- )
- )
-
- // And everything off the wire is allowed to be nonsense, which means no
- // rather than an exception.
- listOf("f".repeat(128), "not a signature", "").forEach { rubbish ->
- assertNull(
- GroupNostrProfile.of(
- signedEvent = signed(content = """{"name":"x"}""", signature = rubbish),
- chatRoomId = chatRoomId
- ),
- "a profile signed with \"$rubbish\" was accepted"
- )
- }
- }
-
- @Test
- fun `an event of another kind is not a profile`() {
- assertNull(
- GroupNostrProfile.of(
- signedEvent = signed(content = """{"name":"x"}""").copy(kind = 1),
- chatRoomId = chatRoomId
- )
- )
- }
-
- @Test
- fun `the newest profile the group signed is the one that counts`() {
- // kind:0 is replaceable: editing a profile signs a second one, and the
- // second is the answer whatever order the query hands them over in.
- val older = signed(content = """{"name":"What it was called"}""", createdAt = 1_700_000_000)
- val newer = signed(content = """{"name":"What it is called"}""", createdAt = 1_700_000_900)
-
- listOf(listOf(older, newer), listOf(newer, older)).forEach { events ->
- assertEquals(
- "What it is called",
- GroupNostrProfile.newestAmong(events, chatRoomId)?.name(),
- "the newest profile lost to query order"
- )
- }
- }
-
- @Test
- fun `two profiles signed in the same second resolve the same way on every device`() {
- // Two devices catching up read the same events in whatever order the
- // relay or the query gives them, and they have to end up showing the same
- // profile. A tie on the timestamp is broken by the id, which every device
- // agrees on because it is a hash of the event.
- val one = signed(content = """{"name":"One"}""", createdAt = 1_700_000_000)
- val other = signed(content = """{"name":"Other"}""", createdAt = 1_700_000_000)
-
- val expected = GroupNostrProfile.newestAmong(listOf(one, other), chatRoomId)?.name()
-
- assertNotNull(expected)
- assertEquals(
- expected,
- GroupNostrProfile.newestAmong(listOf(other, one), chatRoomId)?.name(),
- "a tie on the timestamp was broken differently by the two orderings"
- )
- assertEquals(
- if (one.id > other.id) "One" else "Other",
- expected,
- "the tie should break on the id, which is the only thing both devices share"
- )
- }
-
- @Test
- fun `the first profile a group signs carries the fields it was given`() {
- val template = GroupNostrProfile.template(
- latest = null,
- name = "Translation collective",
- about = "We translate hard books.",
- picture = "https://example.com/group.png",
- website = "https://example.com",
- nip05 = "group@example.com",
- lud16 = "group@getalby.com"
- )
-
- val profile = GroupNostrProfile.of(
- signedEvent = signed(content = template.content),
- chatRoomId = chatRoomId
- )
-
- assertNotNull(profile)
- assertEquals(MetadataEvent.KIND, template.kind)
- assertEquals("Translation collective", profile.name())
- assertEquals("We translate hard books.", profile.about())
- assertEquals("https://example.com/group.png", profile.picture())
- assertEquals("https://example.com", profile.website())
- assertEquals("group@example.com", profile.nip05())
- assertEquals("group@getalby.com", profile.lud16())
- }
-
- @Test
- fun `an edit keeps a field the form does not offer`() {
- // The reason `template` builds on the group's own event rather than from
- // scratch. `bot` is not on the form and never will be reached by it; an
- // edit that dropped it would be this app deciding a group is not a bot
- // because somebody fixed its name.
- val existing = GroupNostrProfile.of(
- signedEvent = signed(content = """{"name":"Old name","bot":true}"""),
- chatRoomId = chatRoomId
- )
- assertNotNull(existing)
-
- val template = GroupNostrProfile.template(
- latest = existing,
- name = "New name",
- about = "",
- picture = "",
- website = "",
- nip05 = "",
- lud16 = ""
- )
-
- val edited = GroupNostrProfile.of(
- signedEvent = signed(content = template.content),
- chatRoomId = chatRoomId
- )
-
- assertNotNull(edited)
- assertEquals("New name", edited.name())
- assertEquals(true, edited.metadata.bot, "an edit dropped a field the form does not offer")
- }
-
- @Test
- fun `an emptied field unsays what the group had said`() {
- // The only way to withdraw something a group has signed. A form that sent
- // nulls for its blanks could add to a profile and never subtract from one.
- val existing = GroupNostrProfile.of(
- signedEvent = signed(
- content = """{"name":"Translation collective","about":"Something regretted"}"""
- ),
- chatRoomId = chatRoomId
- )
- assertNotNull(existing)
-
- val template = GroupNostrProfile.template(
- latest = existing,
- name = "Translation collective",
- about = "",
- picture = "",
- website = "",
- nip05 = "",
- lud16 = ""
- )
-
- val edited = GroupNostrProfile.of(
- signedEvent = signed(content = template.content),
- chatRoomId = chatRoomId
- )
-
- assertNotNull(edited)
- assertNull(edited.about())
- assertEquals("Translation collective", edited.name())
- }
-
- @Test
- fun `a name is written to both of the keys a reader might look in`() {
- val template = GroupNostrProfile.template(
- latest = null,
- name = "Translation collective",
- about = "",
- picture = "",
- website = "",
- nip05 = "",
- lud16 = ""
- )
-
- val metadata = GroupNostrProfile.of(
- signedEvent = signed(content = template.content),
- chatRoomId = chatRoomId
- )?.metadata
-
- assertNotNull(metadata)
- assertEquals("Translation collective", metadata.name)
- assertEquals(
- "Translation collective",
- metadata.displayName,
- "a client preferring display_name would show the old name after an edit"
- )
- }
-
- @Test
- fun `an empty profile is a profile rather than a parse failure`() {
- // Wiping a profile is something a group is entitled to do, and an empty
- // kind:0 is how nostr says it. Reading it as nothing would leave the
- // screen claiming the group had never said anything.
- val profile = GroupNostrProfile.of(
- signedEvent = signed(content = ""),
- chatRoomId = chatRoomId
- )
-
- assertNotNull(profile)
- assertNull(profile.name())
- assertNull(profile.about())
- }
-
- @Test
- fun `content that is not a profile at all is refused`() {
- assertNull(
- GroupNostrProfile.of(
- signedEvent = signed(content = "not json"),
- chatRoomId = chatRoomId
- )
- )
- }
-
- /**
- * A profile row as `FrostSigningManager.complete` files one: the content under
- * the room's own key, with the id hashed over it and the group's signature on
- * it.
- *
- * [signer] is pulled apart from the author so a signature by the wrong quorum
- * can be tested, and [signature] replaces the real one outright for what is
- * not a signature at all.
- */
- private fun signed(
- content: String,
- createdAt: Long = 1_700_000_000,
- material: KeyMaterial = groupMaterial,
- signer: KeyMaterial = material,
- signature: String? = null
- ): GroupSignedEvent = GroupSignedEvent.fromEvent(
- event = metadataEvent(
- content = content,
- createdAt = createdAt,
- material = material,
- signer = signer,
- signature = signature
- ),
- chatRoomId = chatRoomId,
- derivationPath = SharedKeyDerivation.formatPath(path)
- )
-
- private fun metadataEvent(
- content: String,
- createdAt: Long = 1_700_000_000,
- material: KeyMaterial = groupMaterial,
- signer: KeyMaterial = material,
- signature: String? = null
- ): Event {
- val groupPubKey = SharedKeyDerivation
- .derive(material.thresholdPublicKey.value.toHex(), path)
- .hex
-
- val id = EventHasher.hashId(
- pubKey = groupPubKey,
- createdAt = createdAt,
- kind = MetadataEvent.KIND,
- tags = emptyArray(),
- content = content
- )
-
- return Event(
- id = id,
- pubKey = groupPubKey,
- createdAt = createdAt,
- kind = MetadataEvent.KIND,
- tags = emptyArray(),
- content = content,
- sig = signature ?: groupSignature(signer, id)
- )
- }
-
- /**
- * A real FROST signature by [material]'s quorum over [eventId], through the
- * same shape `FrostSigningManager.advance` runs.
- *
- * Assembled any other way it would not be evidence about the signatures this
- * app actually produces.
- */
- private fun groupSignature(material: KeyMaterial, eventId: String): String {
- val cache = SharedKeyDerivation
- .derive(material.thresholdPublicKey.value.toHex(), path)
- .cache
- val message = ByteVector(eventId.hexToByteArray())
- val signerIds = listOf(0, 1)
-
- val nonces = signerIds.map { signerId ->
- SecretNonce.generate(
- sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"),
- secretShare = material.secretShares[signerId],
- publicShare = material.publicShares[signerId],
- tweakedThresholdPublicKey = cache.tweakedPublicKey,
- message = message,
- extraInput = null
- )
- }
-
- val signingSession = Session.create(
- aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!,
- signerIds = signerIds.map { it.toUInt() },
- signerPublicShares = signerIds.map { material.publicShares[it] },
- nParticipants = participants,
- threshold = threshold,
- tweakCache = cache,
- message = message
- )
-
- val partials = signerIds.mapIndexed { position, signerId ->
- signingSession.sign(
- nonces[position].first,
- material.secretShares[signerId],
- signerId.toUInt()
- ).right!!
- }
-
- return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex()
- }
-}
diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupPostTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupPostTest.kt
deleted file mode 100644
index 53fc1c73..00000000
--- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupPostTest.kt
+++ /dev/null
@@ -1,307 +0,0 @@
-package press.mantra.compose.nostr
-
-import press.mantra.compose.database.model.GroupSignedEvent
-import press.mantra.compose.extensions.toHex
-import press.mantra.compose.managers.SharedKeyDerivation
-import com.vitorpamplona.quartz.nip01Core.core.Event
-import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
-import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
-import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
-import fr.acinq.bitcoin.ByteVector
-import fr.acinq.bitcoin.ByteVector32
-import fr.acinq.bitcoin.PrivateKey
-import fr.acinq.bitcoin.crypto.frost.Frost
-import fr.acinq.bitcoin.crypto.frost.IndividualNonce
-import fr.acinq.bitcoin.crypto.frost.KeyMaterial
-import fr.acinq.bitcoin.crypto.frost.SecretNonce
-import fr.acinq.bitcoin.crypto.frost.Session
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertFalse
-import kotlin.test.assertNull
-import kotlin.test.assertTrue
-
-/**
- * What may be shown as something the group said.
- *
- * The reading gate is `GroupNostrProfileTest`'s -- the room has to be the author and
- * the signature has to be the room's -- and it matters more here than anywhere else
- * in this feature. A profile read wrong shows the wrong name; a post read wrong puts
- * words in a group's mouth under a byline saying a quorum agreed to them, on a page
- * whose whole purpose is to be read by people who were not there.
- *
- * The ordering half is the other thing worth pinning. Posts are the one statement
- * here that **accumulates**: kind:1 is not replaceable, so a second post stands
- * beside the first rather than replacing it, and everything the profile and the
- * relay lists do with "newest wins" is wrong for these.
- */
-class GroupPostTest {
- private val participants = 3
- private val threshold = 2
-
- private val groupMaterial: KeyMaterial = Frost.trustedDealerKeygen(
- thresholdSecretKey = PrivateKey(
- ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1")
- ),
- nParticipants = participants,
- threshold = threshold
- )
-
- /** Another group entirely, for the posts signed by the wrong room. */
- private val strangerMaterial: KeyMaterial = Frost.trustedDealerKeygen(
- thresholdSecretKey = PrivateKey(
- ByteVector32("3decade0000000000000000000000000000000000000000000000000000000c3")
- ),
- nParticipants = participants,
- threshold = threshold
- )
-
- private val path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
-
- private val chatRoomId =
- SharedKeyDerivation.marmotGroupId(groupMaterial.thresholdPublicKey.value.toHex(), path)
-
- @Test
- fun `a kind one the room signed is a post by the group`() {
- val post = GroupPost.of(
- signedEvent = signed("We have finished the first chapter."),
- chatRoomId = chatRoomId
- )
-
- assertEquals("We have finished the first chapter.", post?.content)
- assertEquals(chatRoomId, post?.publicKey)
- assertTrue(post?.isNewThread() == true)
- }
-
- @Test
- fun `a kind one another group signed is not this group's post`() {
- // A real quorum and a real signature by a group with no standing to speak
- // for this one. The row names this room because that is where it was filed;
- // the author is what decides whose words they are.
- val stranger = GroupSignedEvent.fromEvent(
- event = noteEvent("Said by somebody else entirely.", material = strangerMaterial),
- chatRoomId = chatRoomId
- )
-
- assertNull(GroupPost.of(signedEvent = stranger, chatRoomId = chatRoomId))
- }
-
- @Test
- fun `a kind one the room did not sign is not a post`() {
- // Authored by the room and signed by somebody else: the shape a member
- // putting words in the group's mouth would produce, and the reason the
- // reading verifies rather than trusting the author field.
- assertNull(
- GroupPost.of(
- signedEvent = signed("The group has decided to disband.", signer = strangerMaterial),
- chatRoomId = chatRoomId
- )
- )
-
- listOf("f".repeat(128), "not a signature", "").forEach { rubbish ->
- assertNull(
- GroupPost.of(
- signedEvent = signed("Anything at all", signature = rubbish),
- chatRoomId = chatRoomId
- ),
- "a post signed with \"$rubbish\" was accepted"
- )
- }
- }
-
- @Test
- fun `an event of another kind is not a post`() {
- assertNull(
- GroupPost.of(
- signedEvent = signed("Hello").copy(kind = 30023),
- chatRoomId = chatRoomId
- )
- )
- }
-
- @Test
- fun `posts accumulate rather than replace, newest first`() {
- // The property that separates these from the profile and the relay lists.
- // A reading that kept only the newest would silently delete a group's whole
- // history the first time it posted twice.
- val first = signed("First", createdAt = 1_700_000_000)
- val second = signed("Second", createdAt = 1_700_000_900)
- val third = signed("Third", createdAt = 1_700_001_800)
-
- listOf(
- listOf(first, second, third),
- listOf(third, second, first),
- listOf(second, third, first),
- ).forEach { events ->
- assertEquals(
- listOf("Third", "Second", "First"),
- GroupPost.newestFirstAmong(events, chatRoomId).map { it.content },
- "posts came back in the wrong order, or one of them went missing"
- )
- }
- }
-
- @Test
- fun `two posts signed in the same second resolve the same way on every device`() {
- // Two devices reading the same posts in whatever order the query hands them
- // over have to show the same feed. A tie on the timestamp breaks on the id,
- // which both agree on because it is a hash of the event.
- val one = signed("One", createdAt = 1_700_000_000)
- val other = signed("Other", createdAt = 1_700_000_000)
-
- val expected = GroupPost.newestFirstAmong(listOf(one, other), chatRoomId).map { it.content }
-
- assertEquals(
- expected,
- GroupPost.newestFirstAmong(listOf(other, one), chatRoomId).map { it.content },
- "a tie on the timestamp was broken differently by the two orderings"
- )
- assertEquals(
- if (one.id > other.id) listOf("One", "Other") else listOf("Other", "One"),
- expected,
- "the tie should break on the id, which is the only thing both devices share"
- )
- }
-
- @Test
- fun `a blank post is dropped rather than drawn as an empty card`() {
- // The composer will not produce one, so anything reaching here empty came
- // from somewhere this app does not control -- and an empty signed card
- // cannot be told from a broken one.
- listOf("", " ", "\n\n").forEach { blank ->
- assertEquals(
- emptyList(),
- GroupPost.newestFirstAmong(listOf(signed(blank)), chatRoomId),
- "a post saying \"$blank\" was shown"
- )
- }
- }
-
- @Test
- fun `a reply is marked as one`() {
- // Nothing here writes replies, so this is about an event that arrived some
- // other way. Drawing one as an announcement would put half a conversation on
- // a group's page with no sign that it was half.
- val reply = GroupPost.of(
- signedEvent = signed(
- "Yes, agreed.",
- tags = arrayOf(arrayOf("e", "b".repeat(64), "", "reply"))
- ),
- chatRoomId = chatRoomId
- )
-
- assertFalse(reply?.isNewThread() == true)
- }
-
- @Test
- fun `the composer builds a bare note and trims it`() {
- // No reply tags, no mentions, no subject: every tag `TextNoteEvent.build`
- // can add describes a relationship to something else, which a group's first
- // way of posting does not have and should not guess at.
- val template = GroupPost.template(" Something the group would say. ")
-
- assertEquals(TextNoteEvent.KIND, template.kind)
- assertEquals("Something the group would say.", template.content)
- assertEquals(emptyList(), template.tags.toList())
- }
-
- @Test
- fun `a post the composer built reads back as the group said it`() {
- val template = GroupPost.template("We have finished the first chapter.")
- val post = GroupPost.of(
- signedEvent = signed(template.content, tags = template.tags),
- chatRoomId = chatRoomId
- )
-
- assertEquals("We have finished the first chapter.", post?.content)
- assertTrue(post?.isNewThread() == true)
- }
-
- /** A post as `FrostSigningManager.complete` files one. */
- private fun signed(
- note: String,
- tags: Array> = emptyArray(),
- createdAt: Long = 1_700_000_000,
- material: KeyMaterial = groupMaterial,
- signer: KeyMaterial = material,
- signature: String? = null
- ): GroupSignedEvent = GroupSignedEvent.fromEvent(
- event = noteEvent(note, tags, createdAt, material, signer, signature),
- chatRoomId = chatRoomId,
- derivationPath = SharedKeyDerivation.formatPath(path)
- )
-
- private fun noteEvent(
- note: String,
- tags: Array> = emptyArray(),
- createdAt: Long = 1_700_000_000,
- material: KeyMaterial = groupMaterial,
- signer: KeyMaterial = material,
- signature: String? = null
- ): Event {
- val groupPubKey = SharedKeyDerivation
- .derive(material.thresholdPublicKey.value.toHex(), path)
- .hex
-
- val id = EventHasher.hashId(
- pubKey = groupPubKey,
- createdAt = createdAt,
- kind = TextNoteEvent.KIND,
- tags = tags,
- content = note
- )
-
- return Event(
- id = id,
- pubKey = groupPubKey,
- createdAt = createdAt,
- kind = TextNoteEvent.KIND,
- tags = tags,
- content = note,
- sig = signature ?: groupSignature(signer, id)
- )
- }
-
- /**
- * A real FROST signature by [material]'s quorum over [eventId], through the
- * same shape `FrostSigningManager.advance` runs.
- */
- private fun groupSignature(material: KeyMaterial, eventId: String): String {
- val cache = SharedKeyDerivation
- .derive(material.thresholdPublicKey.value.toHex(), path)
- .cache
- val message = ByteVector(eventId.hexToByteArray())
- val signerIds = listOf(0, 1)
-
- val nonces = signerIds.map { signerId ->
- SecretNonce.generate(
- sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"),
- secretShare = material.secretShares[signerId],
- publicShare = material.publicShares[signerId],
- tweakedThresholdPublicKey = cache.tweakedPublicKey,
- message = message,
- extraInput = null
- )
- }
-
- val signingSession = Session.create(
- aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!,
- signerIds = signerIds.map { it.toUInt() },
- signerPublicShares = signerIds.map { material.publicShares[it] },
- nParticipants = participants,
- threshold = threshold,
- tweakCache = cache,
- message = message
- )
-
- val partials = signerIds.mapIndexed { position, signerId ->
- signingSession.sign(
- nonces[position].first,
- material.secretShares[signerId],
- signerId.toUInt()
- ).right!!
- }
-
- return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex()
- }
-}
diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupRelayListTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupRelayListTest.kt
deleted file mode 100644
index e58088ab..00000000
--- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupRelayListTest.kt
+++ /dev/null
@@ -1,398 +0,0 @@
-package press.mantra.compose.nostr
-
-import press.mantra.compose.database.model.GroupSignedEvent
-import press.mantra.compose.extensions.toHex
-import press.mantra.compose.managers.SharedKeyDerivation
-import com.vitorpamplona.quartz.nip01Core.core.Event
-import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
-import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
-import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
-import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
-import fr.acinq.bitcoin.ByteVector
-import fr.acinq.bitcoin.ByteVector32
-import fr.acinq.bitcoin.PrivateKey
-import fr.acinq.bitcoin.crypto.frost.Frost
-import fr.acinq.bitcoin.crypto.frost.IndividualNonce
-import fr.acinq.bitcoin.crypto.frost.KeyMaterial
-import fr.acinq.bitcoin.crypto.frost.SecretNonce
-import fr.acinq.bitcoin.crypto.frost.Session
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertFalse
-import kotlin.test.assertNull
-import kotlin.test.assertTrue
-
-/**
- * What a group's relay lists are allowed to be read out of, and what an edit
- * produces.
- *
- * The reading half is `GroupNostrProfileTest`'s again -- the room has to be the
- * author, the signature has to be the room's -- and it is repeated here because
- * these are four different kinds through two different tag vocabularies, and a
- * mistake in any one of them would be invisible in the other three.
- *
- * The writing half is the one that could not be settled by reading the code.
- * NIP-65's markers are absent for both, `read` for read-only and `write` for
- * write-only, so the tag for the ordinary case is the one with the least in it --
- * exactly the shape a round trip is most likely to lose. And "neither" is a state
- * the format cannot express at all, which is why the editor refuses it and why
- * building one drops it rather than writing a tag that means the opposite.
- */
-class GroupRelayListTest {
- private val participants = 3
- private val threshold = 2
-
- private val groupMaterial: KeyMaterial = Frost.trustedDealerKeygen(
- thresholdSecretKey = PrivateKey(
- ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1")
- ),
- nParticipants = participants,
- threshold = threshold
- )
-
- /** Another group entirely, for the lists signed by the wrong room. */
- private val strangerMaterial: KeyMaterial = Frost.trustedDealerKeygen(
- thresholdSecretKey = PrivateKey(
- ByteVector32("3decade0000000000000000000000000000000000000000000000000000000c3")
- ),
- nParticipants = participants,
- threshold = threshold
- )
-
- private val path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
-
- private val chatRoomId =
- SharedKeyDerivation.marmotGroupId(groupMaterial.thresholdPublicKey.value.toHex(), path)
-
- private val one = RelayUrlNormalizer.normalize("wss://relay.one.example")
- private val two = RelayUrlNormalizer.normalize("wss://relay.two.example")
-
- @Test
- fun `every set round trips through the tags it is written in`() {
- // Four kinds and two tag vocabularies: NIP-65's "r" for General and the
- // "relay" tag for the other three, through two RelayTag classes that are
- // different classes in different packages. A set wired to the wrong one
- // writes tags nothing reads back.
- GroupRelaySet.entries.forEach { set ->
- val relays = listOf(GroupRelay(one), GroupRelay(two))
- val list = GroupRelayList.of(
- signedEvent = signed(set.template(relays)),
- chatRoomId = chatRoomId,
- set = set
- )
-
- assertEquals(
- listOf(one, two),
- list?.relays?.map { it.url },
- "${set.name} did not round trip through its own tags"
- )
- }
- }
-
- @Test
- fun `a relay list only reads back as the set it was written as`() {
- // The kinds are the only thing separating a general list from a blocked
- // one, and reading a blocked list as a general one would have the group
- // publishing to the relays it refuses to talk to.
- val blocked = signed(GroupRelaySet.Blocked.template(listOf(GroupRelay(one))))
-
- assertNull(GroupRelayList.of(blocked, chatRoomId, GroupRelaySet.General))
- assertNull(GroupRelayList.of(blocked, chatRoomId, GroupRelaySet.Search))
- assertEquals(
- listOf(one),
- GroupRelayList.of(blocked, chatRoomId, GroupRelaySet.Blocked)?.relays?.map { it.url }
- )
- }
-
- @Test
- fun `read and write markers survive being written and read`() {
- // The ordinary case is the dangerous one: NIP-65 writes both by *omitting*
- // the marker, so a round trip that lost the third element entirely would
- // still look correct for a both-ways relay and would silently turn a
- // read-only relay into a write one.
- val relays = listOf(
- GroupRelay(one, read = true, write = true),
- GroupRelay(two, read = true, write = false),
- GroupRelay(RelayUrlNormalizer.normalize("wss://relay.three.example"),
- read = false, write = true),
- )
-
- val readBack = GroupRelayList.of(
- signedEvent = signed(GroupRelaySet.General.template(relays)),
- chatRoomId = chatRoomId,
- set = GroupRelaySet.General
- )
-
- assertEquals(relays, readBack?.relays)
- }
-
- @Test
- fun `a relay that is neither read nor write is not written at all`() {
- // NIP-65 has no marker for it. The editor will not produce the state, and
- // this is the second line of that defence: writing a bare `r` tag would
- // advertise it as both, which is the opposite of what was asked.
- val list = GroupRelayList.of(
- signedEvent = signed(
- GroupRelaySet.General.template(
- listOf(
- GroupRelay(one, read = false, write = false),
- GroupRelay(two),
- )
- )
- ),
- chatRoomId = chatRoomId,
- set = GroupRelaySet.General
- )
-
- assertEquals(listOf(two), list?.relays?.map { it.url })
- }
-
- @Test
- fun `a relay list another group signed is not this group's`() {
- val stranger = GroupSignedEvent.fromEvent(
- event = relayEvent(
- GroupRelaySet.General.template(listOf(GroupRelay(one))),
- material = strangerMaterial
- ),
- chatRoomId = chatRoomId
- )
-
- assertNull(GroupRelayList.of(stranger, chatRoomId, GroupRelaySet.General))
- }
-
- @Test
- fun `a relay list the room did not sign is not a relay list`() {
- // Authored by the room and signed by somebody else -- the shape a member
- // redirecting a group's traffic would produce.
- assertNull(
- GroupRelayList.of(
- signedEvent = signed(
- GroupRelaySet.General.template(listOf(GroupRelay(one))),
- signer = strangerMaterial
- ),
- chatRoomId = chatRoomId,
- set = GroupRelaySet.General
- )
- )
-
- listOf("f".repeat(128), "not a signature", "").forEach { rubbish ->
- assertNull(
- GroupRelayList.of(
- signedEvent = signed(
- GroupRelaySet.General.template(listOf(GroupRelay(one))),
- signature = rubbish
- ),
- chatRoomId = chatRoomId,
- set = GroupRelaySet.General
- ),
- "a list signed with \"$rubbish\" was accepted"
- )
- }
- }
-
- @Test
- fun `a set the group never agreed reads back unsigned and empty`() {
- // Not null. The screen has a row per set whatever the group has said, and
- // `isAgreed` is what separates "never said" from "said nothing".
- val list = GroupRelayList.newestAmong(emptyList(), chatRoomId, GroupRelaySet.Search)
-
- assertFalse(list.isAgreed)
- assertEquals(emptyList(), list.relays)
- assertEquals(GroupRelaySet.Search, list.set)
- assertNull(list.signedAt)
- }
-
- @Test
- fun `an agreed empty list is not the same as one never agreed`() {
- // A group withdrawing its relay list signs an empty one, and that decision
- // has to survive being read back -- otherwise a deliberate withdrawal looks
- // exactly like never having got round to it.
- val list = GroupRelayList.of(
- signedEvent = signed(GroupRelaySet.Messages.template(emptyList())),
- chatRoomId = chatRoomId,
- set = GroupRelaySet.Messages
- )
-
- assertTrue(list?.isAgreed == true)
- assertEquals(emptyList(), list?.relays)
- }
-
- @Test
- fun `the newest list the group signed is the one that counts`() {
- val older = signed(
- GroupRelaySet.General.template(listOf(GroupRelay(one))),
- createdAt = 1_700_000_000
- )
- val newer = signed(
- GroupRelaySet.General.template(listOf(GroupRelay(two))),
- createdAt = 1_700_000_900
- )
-
- listOf(listOf(older, newer), listOf(newer, older)).forEach { events ->
- assertEquals(
- listOf(two),
- GroupRelayList.newestAmong(events, chatRoomId, GroupRelaySet.General)
- .relays.map { it.url },
- "the newest relay list lost to query order"
- )
- }
- }
-
- @Test
- fun `allAmong sorts every set into one entry each`() {
- val events = listOf(
- signed(GroupRelaySet.General.template(listOf(GroupRelay(one)))),
- signed(GroupRelaySet.Blocked.template(listOf(GroupRelay(two)))),
- )
-
- val all = GroupRelayList.allAmong(events, chatRoomId)
-
- assertEquals(GroupRelaySet.entries.toList(), all.map { it.set })
- assertEquals(listOf(one), all.first { it.set == GroupRelaySet.General }.relays.map { it.url })
- assertEquals(listOf(two), all.first { it.set == GroupRelaySet.Blocked }.relays.map { it.url })
- assertFalse(all.first { it.set == GroupRelaySet.Search }.isAgreed)
- assertFalse(all.first { it.set == GroupRelaySet.Messages }.isAgreed)
- }
-
- @Test
- fun `every set but blocked starts at the app's own relay`() {
- // What a group setting up for the first time is shown. Seeding with nothing
- // would make its first proposal an empty list, which says less than never
- // having said anything.
- GroupRelaySet.entries.filterNot { it == GroupRelaySet.Blocked }.forEach { set ->
- assertEquals(
- listOf(Relays.ephemeral),
- set.defaults().map { it.url },
- "${set.name} did not default to the app's relay"
- )
- }
-
- // And blocked starts empty: a default there is a refusal to talk to
- // somebody that nobody in the group chose.
- assertEquals(emptyList(), GroupRelaySet.Blocked.defaults())
- }
-
- @Test
- fun `only a wss relay somewhere other than this device is accepted`() {
- assertEquals(one, GroupRelaySet.relayUrlOrNull("wss://relay.one.example"))
- assertEquals(one, GroupRelaySet.relayUrlOrNull(" wss://relay.one.example "))
-
- // A group's list is read by strangers over the open network: ws:// asks
- // them to fetch it in the clear, and loopback names a relay only one device
- // can reach.
- listOf(
- "ws://relay.one.example",
- "http://relay.one.example",
- "relay.one.example",
- "wss://localhost",
- "wss://127.0.0.1",
- "",
- ).forEach {
- assertNull(GroupRelaySet.relayUrlOrNull(it), "\"$it\" was accepted as a relay")
- }
- }
-
- @Test
- fun `a duplicated relay is read once`() {
- // Two tags for one relay is one relay said twice, and a list that shows it
- // twice reads as two to a human -- the same reasoning a birth certificate's
- // admin roster is deduplicated on.
- val duplicated = Event(
- id = "",
- pubKey = chatRoomId,
- createdAt = 1_700_000_000,
- kind = AdvertisedRelayListEvent.KIND,
- tags = arrayOf(arrayOf("r", one.url), arrayOf("r", one.url, "read")),
- content = "",
- sig = ""
- )
-
- assertEquals(listOf(one), GroupRelaySet.General.parse(duplicated.tags).map { it.url })
- }
-
- /** A relay list as `FrostSigningManager.complete` files one. */
- private fun signed(
- template: com.vitorpamplona.quartz.nip01Core.signers.EventTemplate<*>,
- createdAt: Long = 1_700_000_000,
- material: KeyMaterial = groupMaterial,
- signer: KeyMaterial = material,
- signature: String? = null
- ): GroupSignedEvent = GroupSignedEvent.fromEvent(
- event = relayEvent(template, createdAt, material, signer, signature),
- chatRoomId = chatRoomId,
- derivationPath = SharedKeyDerivation.formatPath(path)
- )
-
- private fun relayEvent(
- template: com.vitorpamplona.quartz.nip01Core.signers.EventTemplate<*>,
- createdAt: Long = 1_700_000_000,
- material: KeyMaterial = groupMaterial,
- signer: KeyMaterial = material,
- signature: String? = null
- ): Event {
- val groupPubKey = SharedKeyDerivation
- .derive(material.thresholdPublicKey.value.toHex(), path)
- .hex
-
- val id = EventHasher.hashId(
- pubKey = groupPubKey,
- createdAt = createdAt,
- kind = template.kind,
- tags = template.tags,
- content = template.content
- )
-
- return Event(
- id = id,
- pubKey = groupPubKey,
- createdAt = createdAt,
- kind = template.kind,
- tags = template.tags,
- content = template.content,
- sig = signature ?: groupSignature(signer, id)
- )
- }
-
- /**
- * A real FROST signature by [material]'s quorum over [eventId], through the
- * same shape `FrostSigningManager.advance` runs.
- */
- private fun groupSignature(material: KeyMaterial, eventId: String): String {
- val cache = SharedKeyDerivation
- .derive(material.thresholdPublicKey.value.toHex(), path)
- .cache
- val message = ByteVector(eventId.hexToByteArray())
- val signerIds = listOf(0, 1)
-
- val nonces = signerIds.map { signerId ->
- SecretNonce.generate(
- sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"),
- secretShare = material.secretShares[signerId],
- publicShare = material.publicShares[signerId],
- tweakedThresholdPublicKey = cache.tweakedPublicKey,
- message = message,
- extraInput = null
- )
- }
-
- val signingSession = Session.create(
- aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!,
- signerIds = signerIds.map { it.toUInt() },
- signerPublicShares = signerIds.map { material.publicShares[it] },
- nParticipants = participants,
- threshold = threshold,
- tweakCache = cache,
- message = message
- )
-
- val partials = signerIds.mapIndexed { position, signerId ->
- signingSession.sign(
- nonces[position].first,
- material.secretShares[signerId],
- signerId.toUInt()
- ).right!!
- }
-
- return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex()
- }
-}
diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEventTest.kt
deleted file mode 100644
index 1949b9ab..00000000
--- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEventTest.kt
+++ /dev/null
@@ -1,546 +0,0 @@
-package press.mantra.compose.nostr.curated
-
-import com.vitorpamplona.quartz.nip01Core.core.Event
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertFailsWith
-import kotlin.test.assertNotNull
-import kotlin.test.assertNull
-import kotlin.test.assertTrue
-
-/**
- * Kinds 31888 and 31890 as the NIP writes them, read and checked.
- *
- * The fixtures are the NIP's own examples -- the bitcoin.mov schema, *The Rise
- * and Rise of Bitcoin* as `eebb74ab…` suggested it, and the curator signing it
- * off -- because the one property worth pinning is that what that site accepts
- * is accepted here and what it refuses is refused here for the same reason.
- * `CuratedEntryEvent` is a port of the reference module's verifier rule for
- * rule, and this is where each rule gets a case.
- *
- * The other thing pinned is the shape of what comes back: an entry keyed by the
- * schema's field names, with the tags the schema does not define ignored rather
- * than kept. `director`, `i` and `lang` are on the NIP's example and on no
- * field of the NIP's schema, and they are not in the reading.
- *
- * And the write side is pinned against the same example from the other end:
- * the canonical entry built from the suggestion's values is the NIP's canonical
- * entry, tag for tag, and what the builder writes is what the reader reads
- * back -- which is the property that lets the accept form and the queue screen
- * agree about an entry without either seeing the other's code.
- */
-class CuratedEntryEventTest {
- private val curator = "7c965d8c2acdfd635562da3bcb82596b595be28b008d8b54ec702ed4c67d9d25"
- private val suggester = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860"
- private val stranger = "1".repeat(64)
-
- private val coordinate = "31889:$curator:bitcoin.mov"
-
- /** The NIP's schema, as `CuratedSchemaEventTest` reads it. */
- private val schema: CuratedSchema = assertNotNull(
- CuratedSchemaEvent.parse(
- Event(
- id = "a".repeat(64),
- pubKey = curator,
- createdAt = 1735689600,
- kind = CuratedSchemaEvent.KIND,
- tags = arrayOf(
- arrayOf("d", "bitcoin.mov"),
- arrayOf("title", "bitcoin.mov suggestion"),
- arrayOf("name", "bitcoin.mov"),
- arrayOf("description", "Fields for a bitcoin.mov entry."),
- arrayOf("k", "31888"),
- arrayOf("visibility", "public"),
- arrayOf("relay", "wss://ephemeral.mantra.press"),
- arrayOf("field", "identifier", "token", "required", "", "Identifier", "{\"tag\":\"d\",\"max\":80,\"derived\":true}"),
- arrayOf("field", "title", "text", "required", "", "Title", "{\"max\":200}"),
- arrayOf("field", "year", "year", "optional", "", "Year", "{\"min\":1900,\"max\":2100}"),
- arrayOf("field", "type", "enum", "required", "", "Type", "{\"options\":[\"movie\",\"documentary\",\"short\",\"interview\",\"series\",\"other\"]}"),
- arrayOf("field", "watchUrl", "url", "optional", "", "Watch / reference URL", "{\"tag\":\"r\",\"marker\":\"watch\",\"max\":500,\"repeat\":true}"),
- arrayOf("field", "imdbUrl", "url", "optional", "", "IMDb URL", "{\"tag\":\"r\",\"marker\":\"imdb\",\"max\":500}"),
- arrayOf("field", "image", "image", "optional", "", "Poster image URL (https)", "{\"max\":500}"),
- arrayOf("field", "hashtags", "token", "optional", "", "Hashtags", "{\"tag\":\"t\",\"max\":60,\"repeat\":true,\"derived\":true}"),
- arrayOf("field", "description", "longtext", "optional", "", "Description / review", "{\"tag\":\"content\",\"max\":4000}"),
- arrayOf("require-any", "watchUrl", "imdbUrl"),
- ),
- content = "Fields for a bitcoin.mov entry.",
- sig = "b".repeat(128),
- )
- )
- )
-
- private val review = "Follows programmer Daniel Mross and the early Bitcoin community from 2011 onward."
-
- /** *The Rise and Rise of Bitcoin*, as the NIP's example has it. */
- private val riseAndRise: Array> = arrayOf(
- arrayOf("d", "imdb:tt2821314"),
- arrayOf("title", "The Rise and Rise of Bitcoin"),
- arrayOf("year", "2014"),
- arrayOf("type", "documentary"),
- arrayOf("director", "Nicholas Mross"),
- arrayOf("r", "http://bitcoindoc.com/", "watch"),
- arrayOf("r", "https://www.imdb.com/title/tt2821314/", "imdb"),
- arrayOf("image", "https://upload.wikimedia.org/wikipedia/en/9/96/The_Rise_and_Rise_of_Bitcoin_%282014%29_Film_Poster.jpg"),
- arrayOf("i", "imdb:tt2821314"),
- arrayOf("lang", "en"),
- arrayOf("t", "bitcoin"),
- arrayOf("t", "documentary"),
- arrayOf("a", coordinate, "wss://ephemeral.mantra.press", "root"),
- arrayOf("p", curator),
- arrayOf("k", "31889"),
- )
-
- private val suggestionId = "a809ddc2ad9c7b99e739e872b1b9e99e96ca9a3a4cb28cdf220c74324277eee8"
-
- @Test
- fun `the NIP's suggestion satisfies the NIP's schema, and reads by field`() {
- assertEquals(emptyList(), CuratedEntryEvent.verifySuggestion(suggestion(), schema, coordinate))
-
- val entry = assertNotNull(CuratedEntryEvent.suggestion(suggestion(), schema, coordinate))
-
- assertEquals(CuratedEntryEvent.SUGGESTION_KIND, entry.kind)
- assertEquals(suggester, entry.pubKey)
- assertEquals("imdb:tt2821314", entry.identifier)
- assertEquals("The Rise and Rise of Bitcoin", entry.title)
- assertEquals("31888:$suggester:imdb:tt2821314", entry.coordinate())
- assertNull(entry.source)
-
- // By the schema's field names, not by tag: the two `r` tags land on
- // two fields, the `t` tags on one, and the content on the field that
- // writes there.
- assertEquals("2014", entry.value("year"))
- assertEquals("documentary", entry.value("type"))
- assertEquals("http://bitcoindoc.com/", entry.value("watchUrl"))
- assertEquals("https://www.imdb.com/title/tt2821314/", entry.value("imdbUrl"))
- assertEquals(listOf("bitcoin", "documentary"), entry.values["hashtags"])
- assertEquals(review, entry.value("description"))
-
- // What the schema does not define is not read, however much of it there is.
- assertEquals(
- setOf("identifier", "title", "year", "type", "watchUrl", "imdbUrl", "image", "hashtags", "description"),
- entry.values.keys
- )
- }
-
- @Test
- fun `the minimum suggestion is enough`() {
- // The doc's own minimum: nothing but what the schema insists on, and an
- // IMDb link standing in for a watch link under the require-any rule.
- val minimum = suggestion(
- tags = arrayOf(
- arrayOf("d", "bitcoin-the-end-of-money-as-we-know-it"),
- arrayOf("title", "Bitcoin: The End of Money as We Know It"),
- arrayOf("type", "documentary"),
- arrayOf("r", "https://www.imdb.com/title/tt4654844/", "imdb"),
- arrayOf("t", "bitcoin"),
- arrayOf("a", coordinate, "", "root"),
- ),
- content = ""
- )
-
- assertEquals(emptyList(), CuratedEntryEvent.verifySuggestion(minimum, schema, coordinate))
- assertNull(assertNotNull(CuratedEntryEvent.suggestion(minimum, schema, coordinate)).value("description"))
- }
-
- @Test
- fun `the NIP's canonical entry is the list's own, and points at where it came from`() {
- assertEquals(emptyList(), CuratedEntryEvent.verifyCanonical(canonical(), schema, coordinate, curator))
-
- val entry = assertNotNull(CuratedEntryEvent.canonical(canonical(), schema, coordinate, curator))
-
- assertTrue(entry.isCanonical)
- assertEquals("imdb:tt2821314", entry.identifier)
- assertEquals("31890:$curator:imdb:tt2821314", entry.coordinate())
- assertEquals(
- CuratedSuggestionRef(
- id = suggestionId,
- coordinate = "31888:$suggester:imdb:tt2821314",
- pubKey = suggester
- ),
- entry.source
- )
- }
-
- @Test
- fun `a canonical entry nobody suggested has no source`() {
- val unprompted = canonical(tags = riseAndRise)
-
- assertEquals(emptyList(), CuratedEntryEvent.verifyCanonical(unprompted, schema, coordinate, curator))
- assertNull(assertNotNull(CuratedEntryEvent.canonical(unprompted, schema, coordinate, curator)).source)
- }
-
- @Test
- fun `curation is not delegated`() {
- // The same event, signed by the suggester: a valid suggestion and not a
- // canonical entry, whatever its kind says.
- val notTheCurator = canonical(pubKey = suggester)
-
- assertEquals(
- listOf(CuratedEntryProblem(CuratedEntryEvent.FIELD_CURATOR, CuratedEntryProblem.Reason.NotTheCurator)),
- CuratedEntryEvent.verifyCanonical(notTheCurator, schema, coordinate, curator)
- )
- assertNull(CuratedEntryEvent.canonical(notTheCurator, schema, coordinate, curator))
- }
-
- @Test
- fun `a source that is not a suggestion coordinate is refused`() {
- val malformed = canonical(
- tags = riseAndRise + arrayOf(arrayOf("a", "31888:not-a-pubkey:imdb:tt2821314", "", "mention"))
- )
-
- assertEquals(
- listOf(CuratedEntryProblem(CuratedEntryEvent.FIELD_SOURCE, CuratedEntryProblem.Reason.SourceMalformed)),
- CuratedEntryEvent.verifyCanonical(malformed, schema, coordinate, curator)
- )
- }
-
- @Test
- fun `the wrong kind is not an entry of either sort`() {
- val note = suggestion(kind = 1)
-
- assertEquals(
- listOf(CuratedEntryProblem(CuratedEntryEvent.FIELD_KIND, CuratedEntryProblem.Reason.WrongKind)),
- CuratedEntryEvent.verifySuggestion(note, schema, coordinate)
- )
- // And a suggestion is not a canonical entry: same tags, same author rule aside.
- assertTrue(
- CuratedEntryEvent.verifyCanonical(suggestion(), schema, coordinate, curator)
- .any { it.reason == CuratedEntryProblem.Reason.WrongKind }
- )
- }
-
- @Test
- fun `a suggestion has to reply to this schema`() {
- assertEquals(
- listOf(CuratedEntryProblem(CuratedEntryEvent.FIELD_SCHEMA, CuratedEntryProblem.Reason.NotAReply)),
- CuratedEntryEvent.verifySuggestion(suggestion(tags = riseAndRise.without("a")), schema, coordinate)
- )
-
- // Somebody else's list with the same identifier is somebody else's list.
- val elsewhere = riseAndRise.replacing("a", "31889:$stranger:bitcoin.mov")
- assertEquals(
- listOf(CuratedEntryProblem(CuratedEntryEvent.FIELD_SCHEMA, CuratedEntryProblem.Reason.RepliesElsewhere)),
- CuratedEntryEvent.verifySuggestion(suggestion(tags = elsewhere), schema, coordinate)
- )
- }
-
- @Test
- fun `a required field has to be there`() {
- assertEquals(
- listOf(CuratedEntryProblem("title", CuratedEntryProblem.Reason.Missing)),
- CuratedEntryEvent.verifySuggestion(suggestion(tags = riseAndRise.without("title")), schema, coordinate)
- )
- assertEquals(
- listOf(CuratedEntryProblem("type", CuratedEntryProblem.Reason.Missing)),
- CuratedEntryEvent.verifySuggestion(suggestion(tags = riseAndRise.without("type")), schema, coordinate)
- )
- }
-
- @Test
- fun `a field that does not repeat appears once`() {
- val twoPosters = riseAndRise + arrayOf(arrayOf("image", "https://example.com/other.jpg"))
- assertEquals(
- listOf(CuratedEntryProblem("image", CuratedEntryProblem.Reason.Repeated)),
- CuratedEntryEvent.verifySuggestion(suggestion(tags = twoPosters), schema, coordinate)
- )
-
- // Where the schema says `repeat`, two is fine -- and both are read.
- val twoWatchLinks = riseAndRise + arrayOf(arrayOf("r", "https://vimeo.com/1", "watch"))
- assertEquals(emptyList(), CuratedEntryEvent.verifySuggestion(suggestion(tags = twoWatchLinks), schema, coordinate))
- assertEquals(
- listOf("http://bitcoindoc.com/", "https://vimeo.com/1"),
- CuratedEntryEvent.read(suggestion(tags = twoWatchLinks), schema).values["watchUrl"]
- )
- }
-
- @Test
- fun `every value has to match its field`() {
- fun problemsWith(tag: String, value: String) =
- CuratedEntryEvent.verifySuggestion(suggestion(tags = riseAndRise.replacing(tag, value)), schema, coordinate)
-
- // The things the doc lists as rejected, and why they matter.
- assertEquals(listOf(CuratedEntryProblem("type", CuratedEntryProblem.Reason.NotAnOption)), problemsWith("type", "musical"))
- assertEquals(listOf(CuratedEntryProblem("year", CuratedEntryProblem.Reason.BelowMinimum)), problemsWith("year", "1800"))
- assertEquals(listOf(CuratedEntryProblem("year", CuratedEntryProblem.Reason.AboveMaximum)), problemsWith("year", "2200"))
- assertEquals(listOf(CuratedEntryProblem("year", CuratedEntryProblem.Reason.NotAWholeNumber)), problemsWith("year", "twenty fourteen"))
- assertEquals(listOf(CuratedEntryProblem("image", CuratedEntryProblem.Reason.NotHttps)), problemsWith("image", "http://example.com/poster.jpg"))
- assertEquals(listOf(CuratedEntryProblem("title", CuratedEntryProblem.Reason.TooLong)), problemsWith("title", "x".repeat(201)))
- assertEquals(listOf(CuratedEntryProblem("identifier", CuratedEntryProblem.Reason.NotOneWord)), problemsWith("d", "imdb tt2821314"))
-
- // A `javascript:` link ends up in an href. Replacing the watch link
- // leaves the IMDb one, so require-any is still met and the URL is the
- // only complaint.
- val scripted = riseAndRise.map {
- if (it[0] == "r" && it[2] == "watch") arrayOf("r", "javascript:alert(1)", "watch") else it
- }.toTypedArray()
- assertEquals(
- listOf(CuratedEntryProblem("watchUrl", CuratedEntryProblem.Reason.NotAUrl)),
- CuratedEntryEvent.verifySuggestion(suggestion(tags = scripted), schema, coordinate)
- )
- }
-
- @Test
- fun `a require-any group needs one of its fields`() {
- val noLinks = riseAndRise.filter { it[0] != "r" }.toTypedArray()
-
- // Filed under the group's first field, so a form can show it inline.
- assertEquals(
- listOf(CuratedEntryProblem("watchUrl", CuratedEntryProblem.Reason.RequireAnyUnmet)),
- CuratedEntryEvent.verifySuggestion(suggestion(tags = noLinks), schema, coordinate)
- )
- }
-
- @Test
- fun `a closed list takes suggestions from the curator and the suggesters it names`() {
- val closed = schema.copy(visibility = CuratedVisibility.Closed, suggesters = listOf(suggester))
-
- assertEquals(emptyList(), CuratedEntryEvent.verifySuggestion(suggestion(pubKey = suggester), closed, coordinate))
- assertEquals(emptyList(), CuratedEntryEvent.verifySuggestion(suggestion(pubKey = curator), closed, coordinate))
- assertEquals(
- listOf(CuratedEntryProblem(CuratedEntryEvent.FIELD_VISIBILITY, CuratedEntryProblem.Reason.NotPermitted)),
- CuratedEntryEvent.verifySuggestion(suggestion(pubKey = stranger), closed, coordinate)
- )
-
- // Private is the same rule; the difference is who is shown the list, not who may write to it.
- val private = closed.copy(visibility = CuratedVisibility.Private)
- assertEquals(
- listOf(CuratedEntryProblem(CuratedEntryEvent.FIELD_VISIBILITY, CuratedEntryProblem.Reason.NotPermitted)),
- CuratedEntryEvent.verifySuggestion(suggestion(pubKey = stranger), private, coordinate)
- )
- }
-
- @Test
- fun `a value is checked by its type and its config`() {
- val duration = CuratedField("runtime", CuratedFieldType.Duration, isRequired = false, config = CuratedFieldConfig(max = 36000))
- assertEquals(CuratedEntryProblem.Reason.NotPositive, CuratedEntryEvent.checkValue(duration, "0"))
- assertEquals(CuratedEntryProblem.Reason.AboveMaximum, CuratedEntryEvent.checkValue(duration, "36001"))
- // A numeric `max` is a bound, not a length: six digits under the bound is fine.
- assertNull(CuratedEntryEvent.checkValue(duration, " 5400 "))
-
- val pattern = CuratedField("code", CuratedFieldType.Token, isRequired = false, config = CuratedFieldConfig(pattern = "[a-z]{2,8}"))
- assertNull(CuratedEntryEvent.checkValue(pattern, "abc"))
- // End to end, not anywhere within.
- assertEquals(CuratedEntryProblem.Reason.PatternMismatch, CuratedEntryEvent.checkValue(pattern, "abc1"))
-
- // A pattern this platform cannot compile is a check it cannot make, and
- // an entry it cannot check is not one it vouches for.
- val broken = pattern.copy(config = CuratedFieldConfig(pattern = "[unclosed"))
- assertEquals(CuratedEntryProblem.Reason.PatternMismatch, CuratedEntryEvent.checkValue(broken, "abc"))
-
- assertEquals(CuratedEntryProblem.Reason.Empty, CuratedEntryEvent.checkValue(pattern, " "))
-
- val strict = CuratedField("site", CuratedFieldType.Url, isRequired = false, config = CuratedFieldConfig(https = true))
- assertEquals(CuratedEntryProblem.Reason.NotHttps, CuratedEntryEvent.checkValue(strict, "http://example.com"))
- assertNull(CuratedEntryEvent.checkValue(strict, "https://example.com"))
- }
-
- @Test
- fun `a coordinate splits on the first two colons only`() {
- val parsed = assertNotNull(CuratedCoordinate.parse("31888:${suggester.uppercase()}:imdb:tt2821314"))
-
- assertEquals(31888, parsed.kind)
- assertEquals(suggester, parsed.pubKey, "the pubkey was not lowercased")
- assertEquals("imdb:tt2821314", parsed.identifier, "the identifier's own colon split it")
- assertEquals("31888:$suggester:imdb:tt2821314", parsed.toValue())
-
- assertNull(CuratedCoordinate.parse("31888:$suggester:"), "an empty identifier was accepted")
- assertNull(CuratedCoordinate.parse("31888:not-a-pubkey:x"))
- assertNull(CuratedCoordinate.parse("naddr1qqxnzd3cxqmrzv3exgmr2wfeqgs"), "a bech32 address is not a coordinate")
- }
-
- // -- the write side -----------------------------------------------------------
-
- /** The NIP's example, less the tags its schema does not define, which the builder cannot know to write. */
- private val riseAndRiseAsTheSchemaKnowsIt: List> = riseAndRise
- .filterNot { it[0] == "director" || it[0] == "i" || it[0] == "lang" }
- .map { it.toList() }
-
- private val sourceBlock: List> = listOf(
- listOf("a", "31888:$suggester:imdb:tt2821314", "", "mention"),
- listOf("e", suggestionId, "", "mention"),
- listOf("p", suggester),
- )
-
- @Test
- fun `the canonical entry built from the NIP's suggestion is the NIP's canonical entry`() {
- val suggested = CuratedEntryEvent.read(suggestion(), schema)
-
- val template = CuratedEntryEvent.canonicalTemplate(
- schema = schema,
- coordinate = coordinate,
- curator = curator,
- values = suggested.values,
- source = CuratedSuggestionRef.of(suggested),
- createdAt = 1735689720,
- )
-
- assertEquals(CuratedEntryEvent.CANONICAL_KIND, template.kind)
- assertEquals(review, template.content)
- // The first two blocks identical to the suggestion's, the third the
- // pointer back: the doc's own description of the example.
- assertEquals(riseAndRiseAsTheSchemaKnowsIt + sourceBlock, template.tags.map { it.toList() })
-
- val preview = CuratedEntryEvent.preview(template, curator)
- assertEquals(emptyList(), CuratedEntryEvent.verifyCanonical(preview, schema, coordinate, curator))
-
- // And it reads back as what went in, which is the property the form
- // and the queue rely on: the values, the `d`, and where it came from.
- val entry = assertNotNull(CuratedEntryEvent.canonical(preview, schema, coordinate, curator))
- assertEquals(suggested.values, entry.values)
- assertEquals("imdb:tt2821314", entry.identifier)
- assertEquals(CuratedSuggestionRef(suggestionId, "31888:$suggester:imdb:tt2821314", suggester), entry.source)
- }
-
- @Test
- fun `an edit on the way through is what is signed, and the d is what was suggested`() {
- val suggested = CuratedEntryEvent.read(suggestion(), schema)
- val corrected = suggested.values + mapOf(
- "year" to listOf("2015"),
- "image" to listOf("https://example.com/a-better-poster.jpg"),
- )
-
- val template = CuratedEntryEvent.canonicalTemplate(schema, coordinate, curator, corrected, CuratedSuggestionRef.of(suggested))
- val preview = CuratedEntryEvent.preview(template, curator)
-
- assertEquals(emptyList(), CuratedEntryEvent.verifyCanonical(preview, schema, coordinate, curator))
-
- val entry = assertNotNull(CuratedEntryEvent.canonical(preview, schema, coordinate, curator))
- assertEquals("2015", entry.value("year"))
- assertEquals("https://example.com/a-better-poster.jpg", entry.value("image"))
- // Same coordinate as before the edit, so re-curating revises rather than adds.
- assertEquals("31890:$curator:imdb:tt2821314", entry.coordinate())
- }
-
- @Test
- fun `a value the schema refuses is refused with its field named, not clipped`() {
- val suggested = CuratedEntryEvent.read(suggestion(), schema)
- val wrong = suggested.values + mapOf(
- "year" to listOf("twenty fourteen"),
- "title" to listOf("x".repeat(201)),
- "type" to listOf("musical"),
- )
-
- val template = CuratedEntryEvent.canonicalTemplate(schema, coordinate, curator, wrong)
- val problems = CuratedEntryEvent.verifyCanonical(CuratedEntryEvent.preview(template, curator), schema, coordinate, curator)
-
- assertEquals(
- setOf(
- CuratedEntryProblem("title", CuratedEntryProblem.Reason.TooLong),
- CuratedEntryProblem("year", CuratedEntryProblem.Reason.NotAWholeNumber),
- CuratedEntryProblem("type", CuratedEntryProblem.Reason.NotAnOption),
- ),
- problems.toSet()
- )
-
- // The reference clips a long title to its cap; this does not, because
- // the form shows the problem and the member decides how to shorten it.
- assertEquals("x".repeat(201), template.tags.first { it[0] == "title" }[1])
- }
-
- @Test
- fun `a blank is not a value, a field that repeats takes all of them, and one that does not takes the first`() {
- val template = CuratedEntryEvent.canonicalTemplate(
- schema = schema,
- coordinate = coordinate,
- curator = curator,
- values = mapOf(
- "identifier" to listOf("imdb:tt2821314"),
- "title" to listOf(" The Rise and Rise of Bitcoin ", "A second title nobody asked for"),
- "type" to listOf("documentary"),
- "year" to listOf(" "),
- "watchUrl" to listOf("http://bitcoindoc.com/", "", "https://mirror.example/watch"),
- "description" to listOf(""),
- ),
- )
-
- val tags = template.tags.map { it.toList() }
-
- assertEquals(listOf(listOf("title", "The Rise and Rise of Bitcoin")), tags.filter { it[0] == "title" })
- assertEquals(emptyList(), tags.filter { it[0] == "year" })
- assertEquals(
- listOf(
- listOf("r", "http://bitcoindoc.com/", "watch"),
- listOf("r", "https://mirror.example/watch", "watch"),
- ),
- tags.filter { it[0] == "r" }
- )
- assertEquals("", template.content)
- assertEquals(
- emptyList(),
- CuratedEntryEvent.verifyCanonical(CuratedEntryEvent.preview(template, curator), schema, coordinate, curator)
- )
- }
-
- @Test
- fun `an entry nobody suggested has no source block, and the root carries the schema's first relay`() {
- val values = CuratedEntryEvent.read(suggestion(), schema).values
-
- val template = CuratedEntryEvent.canonicalTemplate(schema, coordinate, curator, values, source = null)
- val tags = template.tags.map { it.toList() }
-
- assertEquals(listOf(listOf("a", coordinate, "wss://ephemeral.mantra.press", "root")), tags.filter { it[0] == "a" })
- assertEquals(listOf(listOf("p", curator)), tags.filter { it[0] == "p" })
- assertEquals(emptyList(), tags.filter { it[0] == "e" })
-
- val entry = assertNotNull(
- CuratedEntryEvent.canonical(CuratedEntryEvent.preview(template, curator), schema, coordinate, curator)
- )
- assertNull(entry.source)
- }
-
- @Test
- fun `only a suggestion can be pointed at`() {
- val theListsOwn = assertNotNull(CuratedEntryEvent.canonical(canonical(), schema, coordinate, curator))
-
- assertFailsWith { CuratedSuggestionRef.of(theListsOwn) }
- }
-
- @Test
- fun `the headline needs no schema`() {
- val entry = CuratedEntryEvent.headline(canonical())
-
- assertEquals("The Rise and Rise of Bitcoin", entry.title)
- assertEquals("imdb:tt2821314", entry.identifier)
- assertEquals(suggester, entry.source?.pubKey)
- assertEquals(emptyMap(), entry.values)
- }
-
- private fun suggestion(
- tags: Array> = riseAndRise,
- content: String = review,
- kind: Int = CuratedEntryEvent.SUGGESTION_KIND,
- pubKey: String = suggester,
- ): Event = Event(
- id = suggestionId,
- pubKey = pubKey,
- createdAt = 1735689660,
- kind = kind,
- tags = tags,
- content = content,
- sig = "b".repeat(128),
- )
-
- /** The curator signing off on the suggestion, as the NIP's example has it. */
- private fun canonical(
- tags: Array> = riseAndRise + arrayOf(
- arrayOf("a", "31888:$suggester:imdb:tt2821314", "", "mention"),
- arrayOf("e", suggestionId, "", "mention"),
- arrayOf("p", suggester),
- ),
- pubKey: String = curator,
- ): Event = Event(
- id = "c".repeat(64),
- pubKey = pubKey,
- createdAt = 1735689720,
- kind = CuratedEntryEvent.CANONICAL_KIND,
- tags = tags,
- content = review,
- sig = "b".repeat(128),
- )
-
- private fun Array>.without(tag: String): Array> =
- filter { it[0] != tag }.toTypedArray()
-
- private fun Array>.replacing(tag: String, value: String): Array> =
- map { if (it[0] == tag) arrayOf(tag, value) + it.drop(2) else it }.toTypedArray()
-}
diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/curated/CuratedSchemaEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/curated/CuratedSchemaEventTest.kt
deleted file mode 100644
index 0270d58f..00000000
--- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/curated/CuratedSchemaEventTest.kt
+++ /dev/null
@@ -1,450 +0,0 @@
-package press.mantra.compose.nostr.curated
-
-import com.vitorpamplona.quartz.nip01Core.core.Event
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertFalse
-import kotlin.test.assertNotNull
-import kotlin.test.assertNull
-import kotlin.test.assertTrue
-
-/**
- * Kind 31889 as the NIP writes it, read and written back.
- *
- * The fixture is the bitcoin.mov schema from the NIP's own example, tag for tag,
- * because the point of publishing a schema is that another client can drive its
- * form from it -- so the one thing worth pinning is that what that site published
- * reads here, and that what this app signs would read there.
- *
- * The other half is the rejection list. A schema is what suggestions are checked
- * against, and a reader that repaired a broken one would be accepting entries
- * against a form nobody signed. Every rule the NIP says a client MUST refuse on
- * has a case here, and so does the one repair it says a client MUST make.
- */
-class CuratedSchemaEventTest {
- private val curator = "7c965d8c2acdfd635562da3bcb82596b595be28b008d8b54ec702ed4c67d9d25"
-
- private val description = "Fields for a bitcoin.mov entry: a Bitcoin movie, documentary, " +
- "short, interview or series, with at least one link to watch or look it up."
-
- /** The NIP's example, as published. */
- private val bitcoinMov: Array> = arrayOf(
- arrayOf("d", "bitcoin.mov"),
- arrayOf("title", "bitcoin.mov suggestion"),
- arrayOf("name", "bitcoin.mov"),
- arrayOf("description", description),
- arrayOf("k", "31888"),
- arrayOf("visibility", "public"),
- arrayOf("picture", "https://bitcoin.mov/icon.svg"),
- arrayOf("domain", "bitcoin.mov"),
- arrayOf("relay", "wss://ephemeral.mantra.press"),
- arrayOf("field", "identifier", "token", "required", "the-rise-and-rise-of-bitcoin-2014", "Identifier", "{\"tag\":\"d\",\"max\":80,\"derived\":true}"),
- arrayOf("field", "title", "text", "required", "The Rise and Rise of Bitcoin", "Title", "{\"max\":200}"),
- arrayOf("field", "year", "year", "optional", "2014", "Year", "{\"min\":1900,\"max\":2100}"),
- arrayOf("field", "type", "enum", "required", "documentary", "Type", "{\"options\":[\"movie\",\"documentary\",\"short\",\"interview\",\"series\",\"other\"]}"),
- arrayOf("field", "watchUrl", "url", "optional", "https://youtube.com/watch?v=…", "Watch / reference URL", "{\"tag\":\"r\",\"marker\":\"watch\",\"max\":500,\"repeat\":true}"),
- arrayOf("field", "imdbUrl", "url", "optional", "https://imdb.com/title/tt2821314", "IMDb URL", "{\"tag\":\"r\",\"marker\":\"imdb\",\"max\":500}"),
- arrayOf("field", "image", "image", "optional", "https://…/poster.jpg", "Poster image URL (https)", "{\"max\":500}"),
- arrayOf("field", "hashtags", "token", "optional", "bitcoin", "Hashtags", "{\"tag\":\"t\",\"max\":60,\"repeat\":true,\"derived\":true}"),
- arrayOf("field", "description", "longtext", "optional", "Why is this worth watching?", "Description / review", "{\"tag\":\"content\",\"max\":4000}"),
- arrayOf("require-any", "watchUrl", "imdbUrl"),
- )
-
- @Test
- fun `the bitcoin_mov schema reads as published`() {
- val schema = assertNotNull(CuratedSchemaEvent.parse(event()))
-
- assertEquals("bitcoin.mov", schema.identifier)
- assertEquals("bitcoin.mov suggestion", schema.title)
- assertEquals("bitcoin.mov", schema.name)
- assertEquals(description, schema.description)
- assertEquals(CuratedVisibility.Public, schema.visibility)
- assertEquals("https://bitcoin.mov/icon.svg", schema.picture)
- assertEquals("bitcoin.mov", schema.domain)
- assertEquals(31888, schema.suggestionKind)
- assertEquals(listOf("wss://ephemeral.mantra.press"), schema.relays)
- assertEquals(listOf(listOf("watchUrl", "imdbUrl")), schema.requireAny)
- assertEquals(emptyList(), schema.suggesters)
-
- assertEquals(
- listOf("identifier", "title", "year", "type", "watchUrl", "imdbUrl", "image", "hashtags", "description"),
- schema.fields.map { it.name }
- )
-
- // The positional parts and the config, on the field that uses most of them.
- val watchUrl = schema.fields.first { it.name == "watchUrl" }
- assertEquals(CuratedFieldType.Url, watchUrl.type)
- assertFalse(watchUrl.isRequired)
- assertEquals("https://youtube.com/watch?v=…", watchUrl.placeholder)
- assertEquals("Watch / reference URL", watchUrl.label)
- assertEquals("r", watchUrl.tag())
- assertEquals("watch", watchUrl.config.marker)
- assertEquals(500L, watchUrl.config.max)
- assertEquals(true, watchUrl.config.repeat)
-
- val type = schema.fields.first { it.name == "type" }
- assertEquals(listOf("movie", "documentary", "short", "interview", "series", "other"), type.config.options)
-
- val identifier = schema.fields.first { it.name == "identifier" }
- assertEquals("d", identifier.tag())
- assertEquals(true, identifier.config.derived)
- assertTrue(identifier.isRequired)
-
- // Derived fields are filled in by the client, so a form does not ask.
- assertEquals(
- listOf("title", "year", "type", "watchUrl", "imdbUrl", "image", "description"),
- schema.formFields().map { it.name }
- )
- }
-
- @Test
- fun `the domain stands in for the name wherever the list is shown`() {
- val schema = assertNotNull(CuratedSchemaEvent.parse(event()))
- assertEquals("bitcoin.mov", schema.displayName())
-
- val withoutDomain = assertNotNull(
- CuratedSchemaEvent.parse(event(tags = bitcoinMov.without("domain").replacing("name", "Bitcoin on screen")))
- )
- assertEquals("Bitcoin on screen", withoutDomain.displayName())
- }
-
- @Test
- fun `a schema round trips through the template`() {
- val read = assertNotNull(CuratedSchemaEvent.parse(event()))
-
- val template = CuratedSchemaEvent.template(read, createdAt = 1735689600)
- assertEquals(CuratedSchemaEvent.KIND, template.kind)
- // The content mirrors the description, for clients that read content.
- assertEquals(description, template.content)
-
- val reread = assertNotNull(CuratedSchemaEvent.parse(event(tags = template.tags, content = template.content)))
- assertEquals(read, reread)
-
- // And the tags come out in the NIP's order, identity first.
- assertEquals(
- listOf("d", "title", "name", "description", "k", "visibility", "picture", "domain"),
- template.tags.take(8).map { it[0] }
- )
- assertEquals(9, template.tags.count { it[0] == "field" })
- assertEquals(listOf("require-any", "watchUrl", "imdbUrl"), template.tags.first { it[0] == "require-any" }.toList())
- assertEquals(listOf("relay", "wss://ephemeral.mantra.press"), template.tags.last().toList())
- }
-
- @Test
- fun `a field tag is written the way the NIP shows it`() {
- val schema = assertNotNull(CuratedSchemaEvent.parse(event()))
- val tag = schema.fields.first { it.name == "watchUrl" }.toTagArray()
-
- assertEquals(
- listOf("field", "watchUrl", "url", "optional", "https://youtube.com/watch?v=…", "Watch / reference URL",
- "{\"tag\":\"r\",\"marker\":\"watch\",\"max\":500,\"repeat\":true}"),
- tag.toList()
- )
- }
-
- @Test
- fun `a config key this app does not know survives a round trip`() {
- // Other clients legitimately add their own. Editing a field here must
- // not strip what one of them meant by it.
- val field = assertNotNull(
- CuratedField.parse(arrayOf("field", "lang", "token", "optional", "en", "Language", "{\"max\":60,\"locale\":\"bcp47\"}"))
- )
-
- assertEquals(60L, field.config.max)
- assertEquals("\"bcp47\"", field.config.others["locale"].toString())
- assertEquals("{\"max\":60,\"locale\":\"bcp47\"}", field.config.toJson())
- }
-
- @Test
- fun `a malformed config costs the field its constraints and not its life`() {
- listOf("{not json", "[1,2]", "\"a string\"", "42", "").forEach { blob ->
- val field = assertNotNull(
- CuratedField.parse(arrayOf("field", "year", "year", "optional", "2014", "Year", blob)),
- "the field was dropped over the config \"$blob\""
- )
- assertEquals(CuratedFieldConfig(), field.config, "the config \"$blob\" read as something")
- }
-
- // A known key of the wrong type is dropped rather than kept as something
- // it is not; the rest of the blob is kept.
- val field = assertNotNull(
- CuratedField.parse(arrayOf("field", "year", "year", "optional", "", "", "{\"max\":\"lots\",\"min\":1900}"))
- )
- assertNull(field.config.max)
- assertEquals(1900L, field.config.min)
- }
-
- @Test
- fun `a field with an unknown type or no name is dropped and the rest are kept`() {
- val schema = assertNotNull(
- CuratedSchemaEvent.parse(
- event(
- tags = bitcoinMov.plus(
- listOf(
- arrayOf("field", "rating", "stars", "optional", "", "", "{}"),
- arrayOf("field", " ", "text", "optional", "", "", "{}"),
- arrayOf("field", "short"),
- )
- )
- )
- )
- )
-
- assertEquals(9, schema.fields.size)
- }
-
- @Test
- fun `a field missing its trailing positions still reads`() {
- val field = assertNotNull(CuratedField.parse(arrayOf("field", "director", "text")))
-
- assertEquals("director", field.name)
- assertFalse(field.isRequired)
- assertEquals("", field.placeholder)
- assertEquals("director", field.labelOrName())
- assertEquals(CuratedFieldConfig(), field.config)
- }
-
- @Test
- fun `a schema missing an identity tag is not usable`() {
- mapOf(
- "d" to CuratedSchemaProblem.IdentifierMissing,
- "title" to CuratedSchemaProblem.TitleMissing,
- "name" to CuratedSchemaProblem.NameMissing,
- ).forEach { (tag, problem) ->
- val tags = without(tag)
- assertNull(CuratedSchemaEvent.parse(event(tags = tags)), "a schema without \"$tag\" was accepted")
- assertEquals(listOf(problem), CuratedSchemaEvent.read(tags, description).problems())
- }
-
- // The description alone has a fallback: the content, which is where
- // generic clients put it. Empty both and it is missing.
- assertNull(CuratedSchemaEvent.parse(event(tags = without("description"), content = "")))
- assertEquals(
- listOf(CuratedSchemaProblem.DescriptionMissing),
- CuratedSchemaEvent.read(without("description"), " ").problems()
- )
- }
-
- @Test
- fun `the description falls back to the content and the tag wins when both are there`() {
- val fromContent = assertNotNull(
- CuratedSchemaEvent.parse(event(tags = without("description"), content = " What the content says. "))
- )
- assertEquals("What the content says.", fromContent.description)
-
- val fromTag = assertNotNull(CuratedSchemaEvent.parse(event(content = "Something else entirely")))
- assertEquals(description, fromTag.description)
- }
-
- @Test
- fun `visibility is never guessed`() {
- assertNull(CuratedSchemaEvent.parse(event(tags = without("visibility"))))
- assertEquals(
- listOf(CuratedSchemaProblem.VisibilityMissing),
- CuratedSchemaEvent.read(without("visibility"), description).problems()
- )
-
- assertNull(CuratedSchemaEvent.parse(event(tags = replacing("visibility", "friends"))))
- assertNull(CuratedSchemaEvent.parse(event(tags = replacing("visibility", ""))))
-
- // Case and whitespace are forgiven; a fourth word is not.
- assertEquals(CuratedVisibility.Closed, CuratedSchemaEvent.parse(event(tags = replacing("visibility", " Closed ")))?.visibility)
- assertEquals(CuratedVisibility.Private, CuratedSchemaEvent.parse(event(tags = replacing("visibility", "PRIVATE")))?.visibility)
- }
-
- @Test
- fun `a schema with no fields is not usable`() {
- val tags = bitcoinMov.filterNot { it[0] == "field" }.toTypedArray()
-
- assertNull(CuratedSchemaEvent.parse(event(tags = tags)))
- assertEquals(listOf(CuratedSchemaProblem.NoFields), CuratedSchemaEvent.read(tags, description).problems())
-
- // Checked before the mandatory fields are put back, which is the NIP's
- // rule: "no field tags" is about what the publisher said.
- val onlyUnparseable = tags.plus(arrayOf("field", "rating", "stars", "optional", "", "", "{}"))
- assertNull(CuratedSchemaEvent.parse(event(tags = onlyUnparseable)))
- }
-
- @Test
- fun `a picture that is not https is not usable`() {
- listOf("http://bitcoin.mov/icon.svg", "bitcoin.mov/icon.svg", "javascript:alert(1)", "https://").forEach { picture ->
- assertNull(
- CuratedSchemaEvent.parse(event(tags = replacing("picture", picture))),
- "a schema with the picture \"$picture\" was accepted"
- )
- }
-
- // Absent is fine; it is optional.
- assertNull(CuratedSchemaEvent.parse(event(tags = without("picture")))?.picture)
- assertTrue(CuratedSchemaEvent.read(without("picture"), description).isUsable())
- }
-
- @Test
- fun `a domain that is not a hostname is not usable, and one that is gets normalized`() {
- listOf("bitcoin", "localhost", "bit coin.mov", "-bitcoin.mov", "bitcoin_mov.example").forEach { domain ->
- assertNull(
- CuratedSchemaEvent.parse(event(tags = replacing("domain", domain))),
- "a schema with the domain \"$domain\" was accepted"
- )
- }
-
- // Lenient on input, strict on what is kept: the things people paste
- // around a hostname come off, and what is left is what is checked.
- assertEquals("bitcoin.mov", CuratedSchemaEvent.parse(event(tags = replacing("domain", "https://Bitcoin.MOV/")))?.domain)
- assertEquals("bitcoin.mov", CuratedSchemaEvent.parse(event(tags = replacing("domain", "bitcoin.mov:8080/x y")))?.domain)
- assertEquals("bitcoin.mov", CuratedSchemaEvent.normalizeDomain("@Bitcoin.mov:443/path."))
- assertTrue(CuratedSchemaEvent.isDomain("sub.example.co.za"))
- assertFalse(CuratedSchemaEvent.isDomain("example"))
- }
-
- @Test
- fun `a relay that is not a relay is not usable`() {
- listOf("https://ephemeral.mantra.press", "ephemeral.mantra.press", "wss://").forEach { relay ->
- assertNull(
- CuratedSchemaEvent.parse(event(tags = replacing("relay", relay))),
- "a schema naming the relay \"$relay\" was accepted"
- )
- }
-
- // The NIP allows ws://, whatever this app will let a group sign.
- assertEquals(listOf("ws://localhost:7777"), CuratedSchemaEvent.parse(event(tags = replacing("relay", "ws://localhost:7777")))?.relays)
-
- // Duplicates collapse; none at all is allowed.
- assertEquals(
- listOf("wss://ephemeral.mantra.press"),
- CuratedSchemaEvent.parse(event(tags = bitcoinMov.plus(arrayOf("relay", "wss://ephemeral.mantra.press"))))?.relays
- )
- assertEquals(emptyList(), CuratedSchemaEvent.parse(event(tags = without("relay")))?.relays)
- }
-
- @Test
- fun `an over-long identity tag is not usable`() {
- assertNull(CuratedSchemaEvent.parse(event(tags = replacing("name", "n".repeat(101)))))
- assertNull(CuratedSchemaEvent.parse(event(tags = replacing("d", "d".repeat(101)))))
- assertNull(CuratedSchemaEvent.parse(event(tags = replacing("title", "t".repeat(201)))))
- assertNull(CuratedSchemaEvent.parse(event(tags = replacing("description", "x".repeat(501)))))
-
- assertNotNull(CuratedSchemaEvent.parse(event(tags = replacing("name", "n".repeat(100)))))
- }
-
- @Test
- fun `the wrong kind is not a schema whatever its tags say`() {
- assertNull(CuratedSchemaEvent.parse(event(kind = 31888)))
- assertNull(CuratedSchemaEvent.parse(event(kind = 30023)))
- }
-
- @Test
- fun `the identifier and the title are forced in whatever the schema says`() {
- // A schema fetched from a hostile relay cannot talk a client into
- // accepting untitled or unaddressable entries.
- val onlyAYear = arrayOf(
- arrayOf("d", "years"),
- arrayOf("title", "A year"),
- arrayOf("name", "Years"),
- arrayOf("description", "Just years."),
- arrayOf("visibility", "public"),
- arrayOf("field", "year", "year", "optional", "", "", "{}"),
- )
-
- val schema = assertNotNull(CuratedSchemaEvent.parse(event(tags = onlyAYear)))
-
- assertEquals(listOf("d", "title", "year"), schema.fields.map { it.tag() })
- assertTrue(schema.fields[0].isRequired)
- assertTrue(schema.fields[1].isRequired)
- assertEquals(CuratedField.IDENTIFIER, schema.fields[0])
- assertEquals(CuratedField.TITLE, schema.fields[1])
-
- // And a title the publisher relaxed is made required again, in place.
- val relaxed = replacing(
- "field",
- "title",
- arrayOf("field", "title", "text", "optional", "The Rise and Rise of Bitcoin", "Title", "{\"max\":200}")
- )
- val repaired = assertNotNull(CuratedSchemaEvent.parse(event(tags = relaxed)))
- assertEquals(9, repaired.fields.size)
- assertTrue(repaired.fields.first { it.name == "title" }.isRequired)
-
- // Running it again changes nothing.
- assertEquals(repaired, repaired.normalized())
- }
-
- @Test
- fun `a require-any naming fewer than two fields says nothing`() {
- val schema = assertNotNull(
- CuratedSchemaEvent.parse(
- event(
- tags = bitcoinMov.plus(
- listOf(
- arrayOf("require-any", "image"),
- arrayOf("require-any"),
- arrayOf("require-any", "year", " ", "type"),
- )
- )
- )
- )
- )
-
- assertEquals(listOf(listOf("watchUrl", "imdbUrl"), listOf("year", "type")), schema.requireAny)
- }
-
- @Test
- fun `suggesters are the p tags that are pubkeys`() {
- val other = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860"
- val schema = assertNotNull(
- CuratedSchemaEvent.parse(
- event(
- tags = replacing("visibility", "closed").plus(
- listOf(
- arrayOf("p", other),
- arrayOf("p", other.uppercase()),
- arrayOf("p", "not a pubkey"),
- )
- )
- )
- )
- )
-
- assertEquals(listOf(other), schema.suggesters)
- }
-
- @Test
- fun `the coordinate is what a suggestion replies to`() {
- assertEquals("31889:$curator:bitcoin.mov", CuratedSchemaEvent.coordinate(curator, "bitcoin.mov"))
-
- // An identifier may itself hold colons, so the coordinate does too.
- assertEquals("31889:$curator:imdb:tt2821314", CuratedSchemaEvent.coordinate(curator, "imdb:tt2821314"))
- }
-
- @Test
- fun `an empty config is written as an empty object`() {
- assertEquals("{}", CuratedFieldConfig().toJson())
- assertEquals("{\"tag\":\"d\",\"max\":80,\"derived\":true}", CuratedField.IDENTIFIER.config.toJson())
- }
-
- private fun event(
- tags: Array> = bitcoinMov,
- content: String = description,
- kind: Int = CuratedSchemaEvent.KIND,
- ): Event = Event(
- id = "a".repeat(64),
- pubKey = curator,
- createdAt = 1735689600,
- kind = kind,
- tags = tags,
- content = content,
- sig = "b".repeat(128),
- )
-
- private fun without(tag: String): Array> = bitcoinMov.without(tag)
-
- private fun replacing(tag: String, value: String): Array> = bitcoinMov.replacing(tag, value)
-
- private fun replacing(tag: String, name: String, with: Array): Array> =
- bitcoinMov.map { if (it[0] == tag && it[1] == name) with else it }.toTypedArray()
-
- private fun Array>.without(tag: String): Array> =
- filter { it[0] != tag }.toTypedArray()
-
- private fun Array>.replacing(tag: String, value: String): Array> =
- map { if (it[0] == tag) arrayOf(tag, value) else it }.toTypedArray()
-}
diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt
index 2de50968..9ecddf7e 100644
--- a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt
+++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt
@@ -1,119 +1,19 @@
package press.mantra.compose.text
-import press.mantra.compose.nostr.curated.CuratedEntryEvent
-import press.mantra.compose.nostr.curated.CuratedSchemaEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
-import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
-import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
-import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import kotlin.test.Test
import kotlin.test.assertEquals
/**
- * How the events a group signs about itself are said to the member signing them.
+ * How an event a group signs is said to the member signing it.
*
- * These are read on the signing screen, in the room's list of proposals and now
- * on the paste screen before proposing, and the same words in all three is the
- * point of there being one mapping. What is pinned is that each kind is said as
- * the thing it is and by the part of it a signer is actually deciding about --
- * a profile by its name, a post by its words, a relay list by its hosts, a
- * schema by its list -- and never as "kind 0" over a wall of JSON.
+ * These are read on the signing screen and in the room's list of proposals, and
+ * the same words in both is the point of there being one mapping. What is pinned
+ * here is the fallback: a kind the mapping has no words for is named by its
+ * number over its content, and never described wrongly.
*/
class ProposedEventTest {
- @Test
- fun `a profile is said by its name and what it says about itself`() {
- val summary = ProposedEvent.summarize(
- event(MetadataEvent.KIND, content = """{"name":"Translation collective","about":"We translate hard books."}""")
- )
-
- assertEquals("Nostr profile", summary.label)
- assertEquals("Translation collective · We translate hard books.", summary.detail)
-
- // Empty and unreadable are different things, and a signer should know which.
- assertEquals("An empty profile", ProposedEvent.summarize(event(MetadataEvent.KIND, content = "{}")).detail)
- assertEquals("Unreadable profile", ProposedEvent.summarize(event(MetadataEvent.KIND, content = "not json")).detail)
- }
-
- @Test
- fun `a post is said by its words`() {
- val summary = ProposedEvent.summarize(event(TextNoteEvent.KIND, content = "We have finished the first chapter."))
-
- assertEquals("Post", summary.label)
- assertEquals("We have finished the first chapter.", summary.detail)
- }
-
- @Test
- fun `a relay list is said by which list it is and its hosts`() {
- val summary = ProposedEvent.summarize(
- event(
- AdvertisedRelayListEvent.KIND,
- tags = arrayOf(arrayOf("r", "wss://relay.one.example"), arrayOf("r", "wss://relay.two.example", "read"))
- )
- )
-
- assertEquals("General relay list", summary.label)
- assertEquals("relay.one.example, relay.two.example", summary.detail)
-
- // An empty list is a withdrawal, which is worth saying in as many words.
- assertEquals("No relays", ProposedEvent.summarize(event(AdvertisedRelayListEvent.KIND)).detail)
- }
-
- @Test
- fun `a schema is said by its list, who may suggest, and how much an entry asks for`() {
- val summary = ProposedEvent.summarize(
- event(
- CuratedSchemaEvent.KIND,
- tags = arrayOf(
- arrayOf("d", "films"),
- arrayOf("title", "Film suggestion"),
- arrayOf("name", "Films worth translating"),
- arrayOf("description", "Films the group would subtitle."),
- arrayOf("visibility", "closed"),
- arrayOf("field", "title", "text", "required", "", "Title", "{}"),
- arrayOf("field", "year", "year", "optional", "", "Year", "{}"),
- ),
- content = "Films the group would subtitle."
- )
- )
-
- assertEquals("Curated schema", summary.label)
- assertEquals("Films worth translating · closed · 2 fields", summary.detail)
- }
-
- @Test
- fun `a curated entry is said by its title and who suggested it`() {
- val suggester = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860"
-
- val summary = ProposedEvent.summarize(
- event(
- CuratedEntryEvent.CANONICAL_KIND,
- tags = arrayOf(
- arrayOf("d", "imdb:tt2821314"),
- arrayOf("title", "The Rise and Rise of Bitcoin"),
- arrayOf("year", "2014"),
- arrayOf("a", "31889:${"7".repeat(64)}:bitcoin.mov", "", "root"),
- arrayOf("a", "31888:$suggester:imdb:tt2821314", "", "mention"),
- arrayOf("p", suggester),
- ),
- content = "Follows the early Bitcoin community."
- )
- )
-
- assertEquals("Curated entry", summary.label)
- assertEquals("The Rise and Rise of Bitcoin · suggested by eebb74ab", summary.detail)
-
- // An entry the group added itself has nobody to credit.
- val unprompted = ProposedEvent.summarize(
- event(
- CuratedEntryEvent.CANONICAL_KIND,
- tags = arrayOf(arrayOf("d", "magic-money"), arrayOf("title", "Magic Money")),
- content = ""
- )
- )
- assertEquals("Magic Money", unprompted.detail)
- }
-
@Test
fun `a kind with no words of its own keeps its number`() {
// Refusing to describe an event is better than describing it wrongly.
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/AcceptCuratedSuggestionScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/AcceptCuratedSuggestionScreenJvmTest.kt
deleted file mode 100644
index 860aef0e..00000000
--- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/AcceptCuratedSuggestionScreenJvmTest.kt
+++ /dev/null
@@ -1,216 +0,0 @@
-package press.mantra.compose.ui.composable
-
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.test.ComposeUiTest
-import androidx.compose.ui.test.ExperimentalTestApi
-import androidx.compose.ui.test.assertCountEquals
-import androidx.compose.ui.test.assertIsDisplayed
-import androidx.compose.ui.test.assertIsNotEnabled
-import androidx.compose.ui.test.onAllNodesWithText
-import androidx.compose.ui.test.onNodeWithText
-import androidx.compose.ui.test.performClick
-import androidx.compose.ui.test.performTextReplacement
-import androidx.compose.ui.test.runDesktopComposeUiTest
-import press.mantra.compose.database.model.ChatRoom
-import press.mantra.compose.database.model.GroupSignedEvent
-import press.mantra.compose.database.model.NostrEvent
-import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.database.model.intermdiate.LocalNostrEvent
-import press.mantra.compose.nostr.CuratedSuggestion
-import press.mantra.compose.nostr.GroupCuratedSchema
-import press.mantra.compose.nostr.curated.CuratedField
-import press.mantra.compose.nostr.curated.CuratedFieldConfig
-import press.mantra.compose.nostr.curated.CuratedFieldType
-import press.mantra.compose.nostr.curated.CuratedSchema
-import press.mantra.compose.nostr.curated.CuratedSchemaEvent
-import press.mantra.compose.nostr.curated.CuratedVisibility
-import press.mantra.compose.repository.ChatRepository
-import press.mantra.compose.repository.FrostSigningRepository
-import press.mantra.compose.repository.NostrRepository
-import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost
-import press.mantra.compose.ui.theme.MantraTheme
-import press.mantra.compose.ui.view.state.AcceptCuratedSuggestionUIState
-import kotlin.test.Test
-import kotlin.time.Instant
-
-/**
- * What the accept form puts on the screen, for a schema it has never seen.
- *
- * Three things worth pinning. **The inputs are the schema's**: one per field
- * the form asks for, labelled as the schema labels them and seeded with what
- * was suggested, with an enum as its options rather than a box to misspell
- * one in. **What is kept is shown, not hidden**: the identifier and the
- * derived fields are on the screen under their own heading, so what the
- * member sees is what the group will sign. And **a refusal is drawn under the
- * input it is about**, since the button doing nothing is indistinguishable
- * from a missed tap.
- */
-@OptIn(ExperimentalTestApi::class)
-class AcceptCuratedSuggestionScreenJvmTest {
-
- private val chatRoomId = "d4c3b2a1".repeat(8)
- private val ada = "a".repeat(64)
- private val bob = "b".repeat(64)
-
- private val schema = GroupCuratedSchema(
- signedEvent = GroupSignedEvent(
- id = "c".repeat(64),
- chatRoomId = chatRoomId,
- publicKey = chatRoomId,
- kind = CuratedSchemaEvent.KIND,
- tags = emptyArray(),
- content = "",
- signature = "f".repeat(128),
- createdAt = Instant.fromEpochSeconds(1_700_000_000)
- ),
- schema = CuratedSchema(
- identifier = "films",
- title = "Film suggestion",
- name = "Films worth translating",
- description = "Films the group would subtitle.",
- visibility = CuratedVisibility.Public,
- domain = "films.example",
- fields = listOf(
- CuratedField.IDENTIFIER,
- CuratedField.TITLE,
- CuratedField("year", CuratedFieldType.Year, isRequired = false, label = "Year", config = CuratedFieldConfig(min = 1900, max = 2100)),
- CuratedField("type", CuratedFieldType.Enum, isRequired = true, label = "Type", config = CuratedFieldConfig(options = listOf("movie", "documentary"))),
- CuratedField("watchUrl", CuratedFieldType.Url, isRequired = false, label = "Watch URL", config = CuratedFieldConfig(tag = "r", marker = "watch", repeat = true)),
- CuratedField("hashtags", CuratedFieldType.Token, isRequired = false, label = "Hashtags", config = CuratedFieldConfig(tag = "t", repeat = true, derived = true)),
- CuratedField("review", CuratedFieldType.LongText, isRequired = false, label = "Review", config = CuratedFieldConfig(tag = CuratedFieldConfig.CONTENT_TAG, hint = "Why the group should watch it")),
- ),
- relays = listOf("wss://relay.one.example")
- ).normalized()
- )
-
- private val suggestion: CuratedSuggestion = CuratedSuggestion.queueOf(
- schema,
- listOf(
- LocalNostrEvent(
- nostrEvent = NostrEvent(
- id = "1".repeat(64),
- pubKey = bob,
- kind = CuratedSchemaEvent.SUGGESTION_KIND,
- tags = arrayOf(
- arrayOf("d", "magic-money"),
- arrayOf("title", "Magic Money"),
- arrayOf("year", "2018"),
- arrayOf("type", "documentary"),
- arrayOf("r", "https://example.com/watch", "watch"),
- arrayOf("t", "bitcoin"),
- arrayOf("a", schema.coordinate(), "", "root"),
- ),
- content = "Follows the early community.",
- sig = "b".repeat(128),
- createdAt = Instant.fromEpochSeconds(1_700_000_000),
- ),
- profile = null
- )
- )
- ).single()
-
- private fun state(canSign: Boolean = true, isCurated: Boolean = false) = AcceptCuratedSuggestionUIState.Loaded(
- localChatRoom = LocalChatRoom(
- chatRoom = ChatRoom(
- id = chatRoomId,
- userPublicKey = ada,
- subject = "Translation room",
- description = null,
- initialGiftWrapPayloadId = "sdfaer",
- mlsGroupState = "state"
- )
- ),
- schema = schema,
- suggestion = suggestion.copy(isCurated = isCurated),
- canSign = canSign
- )
-
- @Test
- fun `the inputs are the schema's, seeded from the suggestion, and what is kept is shown`() = render(state()) {
- // Twice: the heading, and the title input seeded with it.
- onAllNodesWithText("Magic Money").assertCountEquals(2)
- onNodeWithText("Suggested by ${bob.take(8)} on ", substring = true).assertIsDisplayed()
- onNodeWithText("Take this suggestion into films.example", substring = true).assertIsDisplayed()
-
- // The schema's labels over its values: a text field, a number, and the
- // enum as chips with the suggested one among them.
- onNodeWithText("Year").assertIsDisplayed()
- onNodeWithText("2018").assertIsDisplayed()
- onNodeWithText("Type").assertIsDisplayed()
- onNodeWithText("movie").assertIsDisplayed()
- onNodeWithText("documentary").assertIsDisplayed()
- onNodeWithText("https://example.com/watch").assertIsDisplayed()
- onNodeWithText("Follows the early community.").assertIsDisplayed()
- // The schema's hint under the input it is for.
- onNodeWithText("Why the group should watch it").assertIsDisplayed()
- // A field that repeats can be given another value.
- onNodeWithText("Add another").assertIsDisplayed()
-
- // Kept, and said to be: the identifier and the derived hashtags.
- onNodeWithText("Kept from the suggestion").assertIsDisplayed()
- onNodeWithText("Identifier").assertIsDisplayed()
- onNodeWithText("magic-money").assertIsDisplayed()
- onNodeWithText("Hashtags").assertIsDisplayed()
- onNodeWithText("bitcoin").assertIsDisplayed()
-
- onNodeWithText("Propose entry").assertIsDisplayed()
- onAllNodesWithText("The group already has an entry for this", substring = true).assertCountEquals(0)
- }
-
- @Test
- fun `a refusal is drawn under the input it is about`() = render(state()) {
- // The suggested year is replaced with one the schema's range refuses,
- // by way of the field: the test types where a member would.
- onNodeWithText("2018").performTextReplacement("1850")
- onNodeWithText("Propose entry").performClick()
-
- onNodeWithText("At least 1900.").assertIsDisplayed()
- }
-
- @Test
- fun `a device without a share sees why the button is inert`() = render(state(canSign = false)) {
- onNodeWithText("This group has no shared key, so it cannot sign an entry", substring = true).assertIsDisplayed()
- onNodeWithText("Propose entry").assertIsNotEnabled()
- }
-
- @Test
- fun `a suggestion the group already took up says so before the button`() = render(state(isCurated = true)) {
- onNodeWithText("The group already has an entry for this", substring = true).assertIsDisplayed()
- }
-
- /**
- * The screen at a phone's width and a window tall enough to compose the
- * whole of it. The state is passed in, so the NO_OP repositories are never
- * asked for anything: `initiate` only runs for a screen that arrives loading.
- */
- private fun render(
- uiState: AcceptCuratedSuggestionUIState,
- assertions: ComposeUiTest.() -> Unit
- ) = runDesktopComposeUiTest(width = 400, height = 2400) {
- setContent {
- MantraTheme {
- ProvideSnackbarHost {
- Box(modifier = Modifier.fillMaxSize()) {
- AcceptCuratedSuggestionScreen(
- activeUserPublicKey = ada,
- chatRoomId = chatRoomId,
- relayHint = null,
- identifier = "films",
- suggestionId = suggestion.id,
- initialAcceptCuratedSuggestionUIState = uiState,
- nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
- chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
- frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
- onNavigateBack = {},
- onNavigateToRouteAndPopUpInclusive = {}
- )
- }
- }
- }
- }
-
- assertions()
- }
-}
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreenJvmTest.kt
index 327f695d..710d0220 100644
--- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreenJvmTest.kt
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreenJvmTest.kt
@@ -7,13 +7,14 @@ import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.getBoundsInRoot
+import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.runDesktopComposeUiTest
-import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
+import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
@@ -26,9 +27,6 @@ import press.mantra.compose.network.dto.RelayDTO
import press.mantra.compose.network.relays.EventPublishTransport
import press.mantra.compose.network.relays.NostrPublishResult
import press.mantra.compose.network.sockets.NostrIncomingMessage
-import press.mantra.compose.nostr.GroupRelay
-import press.mantra.compose.nostr.GroupRelayList
-import press.mantra.compose.nostr.GroupRelaySet
import press.mantra.compose.nostr.Relays
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost
@@ -41,19 +39,21 @@ import kotlin.time.Instant
/**
* What the broadcast screen shows before, and after, the button.
*
- * Before: the event named as the signing screen would name it, the relays the
- * group has said it lives at already in the list, and a way to add one. After:
- * each relay's answer under its own row, in the relay's words where it gave
- * any, and the sum above them -- because the sum is what the member came for and
- * the words are what they can act on.
+ * Before: the event named as the signing screen would name it, the app's own
+ * publish relays already in the list, and a way to add one. After: each relay's
+ * answer under its own row, in the relay's words where it gave any, and the sum
+ * above them -- because the sum is what the member came for and the words are
+ * what they can act on.
*/
@OptIn(ExperimentalTestApi::class)
class BroadcastGroupSignedEventScreenJvmTest {
private val chatRoomId = "d4c3b2a1".repeat(8)
- private val one = RelayUrlNormalizer.normalize("wss://relay.one.example")
- private val two = RelayUrlNormalizer.normalize("wss://relay.two.example")
+ /** The first two relays the screen seeds, in the order it lists them. */
+ private val seeded = Relays.eventPublishRelaySet.toList()
+ private val one = seeded[0]
+ private val two = seeded[1]
/** A pool that answers as told and never opens a socket. */
private class FakeTransport(
@@ -66,21 +66,24 @@ class BroadcastGroupSignedEventScreenJvmTest {
}
@Test
- fun `the event is named, the group's relays are filled in, and the JSON is there to copy`() =
+ fun `the event is named, the app's relays are filled in, and the JSON is there to copy`() =
render(state()) {
- onNodeWithText("Post").assertIsDisplayed()
+ // A plain note, which the signing screen has no words of its own
+ // for, so it is named by its number over its content.
+ onNodeWithText("Event of kind 1").assertIsDisplayed()
onNodeWithText("The first chapter is out.").assertIsDisplayed()
- // The group's own General list, as hosts.
- onNodeWithText("relay.one.example").assertIsDisplayed()
- onNodeWithText("relay.two.example").assertIsDisplayed()
+ // The app's own publish set, as hosts.
+ seeded.forEach { relay ->
+ onNodeWithText(relay.displayUrl()).assertIsDisplayed()
+ }
onNodeWithText("Broadcast").assertIsDisplayed()
onNodeWithText("Copy raw JSON").assertIsDisplayed()
// The relays come before the JSON: the list is the screen's job and
// the JSON is for whoever would rather use a different tool.
- val relays = onNodeWithText("relay.one.example").getBoundsInRoot().top
+ val relays = onNodeWithText(one.displayUrl()).getBoundsInRoot().top
val json = onNodeWithText("The signed event").getBoundsInRoot().top
assertTrue(relays < json, "the JSON climbed above the relay list")
}
@@ -120,26 +123,32 @@ class BroadcastGroupSignedEventScreenJvmTest {
) {
onNodeWithContentDescription("Broadcast to these relays").performClick()
+ // The two that answered are counted; the rest of the seeded list
+ // never did, and the sum says so by counting them as asked.
waitUntil(timeoutMillis = 5_000) {
- onAllNodesWithText("Accepted by 1 of 2 relays.").fetchSemanticsNodes().isNotEmpty()
+ onAllNodesWithText("Accepted by 1 of ${seeded.size} relays.")
+ .fetchSemanticsNodes().isNotEmpty()
}
onNodeWithText("Accepted").assertIsDisplayed()
onNodeWithText("Rejected: blocked: not on the allow list").assertIsDisplayed()
// The answer under the relay it came from, not in a list of its own.
- val relayOne = onNodeWithText("relay.one.example").getBoundsInRoot().top
+ val relayOne = onNodeWithText(one.displayUrl()).getBoundsInRoot().top
val accepted = onNodeWithText("Accepted").getBoundsInRoot().top
- val relayTwo = onNodeWithText("relay.two.example").getBoundsInRoot().top
+ val relayTwo = onNodeWithText(two.displayUrl()).getBoundsInRoot().top
val rejected = onNodeWithText("Rejected: blocked: not on the allow list").getBoundsInRoot().top
assertTrue(relayOne < accepted && accepted < relayTwo, "the OK is not under its relay")
assertTrue(relayTwo < rejected, "the rejection is not under its relay")
}
@Test
- fun `an empty list asks nobody and says to add one`() = render(
- state(relayLists = GroupRelayList.allAmong(emptyList(), chatRoomId), seedNothing = true)
- ) {
+ fun `an empty list asks nobody and says to add one`() = render(state()) {
+ // Emptied by hand, since the seed always has something in it.
+ repeat(seeded.size) {
+ onAllNodesWithContentDescription("Remove relay")[0].performClick()
+ }
+
onNodeWithText("No relays to broadcast to. Add one above.").assertIsDisplayed()
}
@@ -154,37 +163,7 @@ class BroadcastGroupSignedEventScreenJvmTest {
createdAt = Instant.fromEpochSeconds(1_700_000_000),
)
- /** A General list of two relays, agreed, so the screen has something of the group's to fill in. */
- private fun agreedRelayLists(): List =
- GroupRelayList.allAmong(emptyList(), chatRoomId).map { list ->
- if (list.set != GroupRelaySet.General) return@map list
-
- GroupRelayList(
- set = list.set,
- signedEvent = GroupSignedEvent(
- id = "a".repeat(64),
- chatRoomId = chatRoomId,
- publicKey = chatRoomId,
- kind = list.set.kind,
- tags = emptyArray(),
- content = "",
- signature = "f".repeat(128),
- createdAt = Instant.fromEpochSeconds(1_700_000_000),
- ),
- relays = listOf(GroupRelay(one), GroupRelay(two)),
- )
- }
-
- /**
- * [seedNothing] blocks every relay the seed would otherwise fall back to, so
- * the screen can be seen with an empty list -- a group that has agreed
- * nothing is filled in from the app's own set, which is the point of the
- * fallback and the opposite of what this fixture wants.
- */
- private fun state(
- relayLists: List = agreedRelayLists(),
- seedNothing: Boolean = false,
- ) = BroadcastGroupSignedEventUIState.Loaded(
+ private fun state() = BroadcastGroupSignedEventUIState.Loaded(
localChatRoom = LocalChatRoom(
chatRoom = ChatRoom(
id = chatRoomId,
@@ -196,26 +175,13 @@ class BroadcastGroupSignedEventScreenJvmTest {
)
),
signedEvent = signedEvent(),
- relayLists = if (seedNothing) {
- relayLists.map { list ->
- if (list.set != GroupRelaySet.Blocked) return@map list
-
- GroupRelayList(
- set = list.set,
- signedEvent = null,
- relays = Relays.eventPublishRelaySet.map { GroupRelay(it) },
- )
- }
- } else {
- relayLists
- },
)
private fun render(
uiState: BroadcastGroupSignedEventUIState,
transport: EventPublishTransport = FakeTransport(emptyList()),
assertions: ComposeUiTest.() -> Unit
- ) = runDesktopComposeUiTest(width = 400, height = 1600) {
+ ) = runDesktopComposeUiTest(width = 400, height = 2400) {
setContent {
MantraTheme {
ProvideSnackbarHost {
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreenJvmTest.kt
deleted file mode 100644
index db5ba06c..00000000
--- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreenJvmTest.kt
+++ /dev/null
@@ -1,333 +0,0 @@
-package press.mantra.compose.ui.composable
-
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.test.ComposeUiTest
-import androidx.compose.ui.test.ExperimentalTestApi
-import androidx.compose.ui.test.assertCountEquals
-import androidx.compose.ui.test.assertIsDisplayed
-import androidx.compose.ui.test.getBoundsInRoot
-import androidx.compose.ui.test.onAllNodesWithText
-import androidx.compose.ui.test.onNodeWithText
-import androidx.compose.ui.test.performClick
-import androidx.compose.ui.test.runDesktopComposeUiTest
-import press.mantra.compose.database.model.ChatRoom
-import press.mantra.compose.database.model.GroupSignedEvent
-import press.mantra.compose.database.model.NostrEvent
-import press.mantra.compose.database.model.Profile
-import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.nostr.CuratedSuggestion
-import press.mantra.compose.nostr.GroupCuratedSchema
-import press.mantra.compose.nostr.curated.CuratedEntry
-import press.mantra.compose.nostr.curated.CuratedField
-import press.mantra.compose.nostr.curated.CuratedFieldConfig
-import press.mantra.compose.nostr.curated.CuratedFieldType
-import press.mantra.compose.nostr.curated.CuratedSchema
-import press.mantra.compose.nostr.curated.CuratedSchemaEvent
-import press.mantra.compose.nostr.curated.CuratedVisibility
-import press.mantra.compose.repository.ChatRepository
-import press.mantra.compose.repository.NostrRepository
-import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost
-import press.mantra.compose.ui.theme.MantraTheme
-import press.mantra.compose.ui.view.state.CuratedSuggestionListUIState
-import kotlin.test.Test
-import kotlin.test.assertTrue
-import kotlin.time.Instant
-
-/**
- * What the queue screen says, in each of the states it can be in.
- *
- * Three things are worth pinning. **The row is driven by a schema the screen
- * has never seen**, so what it puts on a row is a decision -- the fields that
- * say what an entry is, labelled, and not the links -- and that decision is
- * checked against a schema with one of each. **"Curated" is per row**, and a
- * screen that marked the wrong row would send a member to re-curate what the
- * group had already taken up. And **empty is not the same as still looking**:
- * the screen has both and must say which, since a queue called empty a second
- * before its rows arrive teaches a member not to believe it.
- */
-@OptIn(ExperimentalTestApi::class)
-class CuratedSuggestionListScreenJvmTest {
-
- private val chatRoomId = "d4c3b2a1".repeat(8)
- private val ada = "a".repeat(64)
- private val bob = "b".repeat(64)
-
- private val schema = GroupCuratedSchema(
- signedEvent = GroupSignedEvent(
- id = "c".repeat(64),
- chatRoomId = chatRoomId,
- publicKey = chatRoomId,
- kind = CuratedSchemaEvent.KIND,
- tags = emptyArray(),
- content = "",
- signature = "f".repeat(128),
- createdAt = Instant.fromEpochSeconds(1_700_000_000)
- ),
- schema = CuratedSchema(
- identifier = "films",
- title = "Film suggestion",
- name = "Films worth translating",
- description = "Films the group would subtitle.",
- visibility = CuratedVisibility.Public,
- domain = "films.example",
- fields = listOf(
- CuratedField.IDENTIFIER,
- CuratedField.TITLE,
- CuratedField("year", CuratedFieldType.Year, isRequired = false, label = "Year"),
- CuratedField("type", CuratedFieldType.Enum, isRequired = true, label = "Type"),
- CuratedField(
- "watchUrl",
- CuratedFieldType.Url,
- isRequired = false,
- label = "Watch URL",
- config = CuratedFieldConfig(tag = "r", marker = "watch")
- ),
- CuratedField(
- "review",
- CuratedFieldType.LongText,
- isRequired = false,
- label = "Review",
- config = CuratedFieldConfig(tag = CuratedFieldConfig.CONTENT_TAG)
- ),
- ),
- relays = listOf("wss://relay.one.example")
- )
- )
-
- @Test
- fun `the queue is named after the list, counted, and read from where the schema says`() = render(
- state(
- suggestions = listOf(
- suggestion("The Rise and Rise of Bitcoin", 1_700_000_900, isCurated = true),
- suggestion("Magic Money", 1_700_000_000, isCurated = false)
- )
- )
- ) {
- // The domain, as the card on the group's screen names it.
- onNodeWithText("films.example").assertIsDisplayed()
- onNodeWithText("2 suggestions · 1 curated").assertIsDisplayed()
- onNodeWithText("Read from relay.one.example").assertIsDisplayed()
-
- // Newest first.
- val newest = onNodeWithText("The Rise and Rise of Bitcoin").getBoundsInRoot().top
- val oldest = onNodeWithText("Magic Money").getBoundsInRoot().top
- assertTrue(newest < oldest, "the queue was drawn oldest first")
- }
-
- @Test
- fun `a row shows what an entry is, not where to find it, and who suggested it`() = render(
- state(
- suggestions = listOf(
- suggestion(
- "Magic Money",
- 1_700_000_000,
- isCurated = false,
- suggester = Profile(
- publicKey = ada,
- displayName = "Ada",
- nostrEventId = "e".repeat(64),
- createdAt = Instant.fromEpochSeconds(1_700_000_000)
- )
- )
- )
- )
- ) {
- // Labelled, because the screen does not know this list's fields any
- // better than a stranger does; the link left to the sheet.
- onNodeWithText("Year: 2018 · Type: documentary").assertIsDisplayed()
- onNodeWithText("https://example.com/watch").assertDoesNotExist()
- // The body, where the list has one.
- onNodeWithText("Follows the early community.").assertIsDisplayed()
- onNodeWithText("Suggested by Ada on", substring = true).assertIsDisplayed()
-
- onNodeWithText("1 suggestion").assertIsDisplayed()
- onNodeWithText("Curated").assertDoesNotExist()
- }
-
- @Test
- fun `only the row the group has taken up is marked`() = render(
- state(
- suggestions = listOf(
- suggestion("The Rise and Rise of Bitcoin", 1_700_000_900, isCurated = true),
- suggestion("Magic Money", 1_700_000_000, isCurated = false)
- )
- )
- ) {
- onAllNodesWithText("Curated").assertCountEquals(1)
-
- val mark = onNodeWithText("Curated").getBoundsInRoot()
- val curated = onNodeWithText("The Rise and Rise of Bitcoin").getBoundsInRoot()
- val waiting = onNodeWithText("Magic Money").getBoundsInRoot()
-
- assertTrue(mark.top < waiting.top, "the mark fell onto the row still waiting")
- assertTrue(mark.bottom > curated.top, "the mark floated above the row it is about")
- }
-
- @Test
- fun `a queue still being asked for is looking, not empty`() = render(
- state(suggestions = emptyList(), isAsking = true)
- ) {
- onNodeWithText("Asking the list's relays…").assertIsDisplayed()
- onNodeWithText("Nobody has suggested anything to this list yet.").assertDoesNotExist()
- onNodeWithText("Ask again").assertDoesNotExist()
- }
-
- @Test
- fun `an empty queue says so, and offers to ask again`() = render(
- state(suggestions = emptyList(), isAsking = false)
- ) {
- onNodeWithText("Nobody has suggested anything to this list yet.").assertIsDisplayed()
- onNodeWithText("Ask again").assertIsDisplayed()
- onNodeWithText("Asking the list's relays…").assertDoesNotExist()
- onNodeWithText("0 suggestions").assertIsDisplayed()
- }
-
- @Test
- fun `the sheet has every field the entry answered, the link included, and the way in`() =
- runDesktopComposeUiTest(width = 400, height = 2000) {
- var accepted = false
-
- setContent {
- MantraTheme {
- ProvideSnackbarHost {
- CuratedSuggestionSheetContent(
- suggestion = suggestion("Magic Money", 1_700_000_000, isCurated = false),
- schema = schema.schema,
- onAccept = { accepted = true }
- )
- }
- }
- }
-
- onNodeWithText("Magic Money").assertIsDisplayed()
-
- // As the schema labels them, in the schema's order.
- onNodeWithText("Year").assertIsDisplayed()
- onNodeWithText("2018").assertIsDisplayed()
- onNodeWithText("Watch URL").assertIsDisplayed()
- onNodeWithText("https://example.com/watch").assertIsDisplayed()
- onNodeWithText("Review").assertIsDisplayed()
- onNodeWithText("Follows the early community.").assertIsDisplayed()
-
- // The one thing to do with it, and not the event: the form shows
- // the entry as fields, which is how the group will sign it.
- onAllNodesWithText("\"kind\":31888", substring = true).assertCountEquals(0)
- onAllNodesWithText("Copy the suggestion").assertCountEquals(0)
- onNodeWithText("Accept this suggestion").assertIsDisplayed().performClick()
- assertTrue(accepted, "the button did not open the form")
- }
-
- @Test
- fun `a suggestion the group already took up offers a revision, not a second acceptance`() =
- runDesktopComposeUiTest(width = 400, height = 2000) {
- setContent {
- MantraTheme {
- ProvideSnackbarHost {
- CuratedSuggestionSheetContent(
- suggestion = suggestion("Magic Money", 1_700_000_000, isCurated = true),
- schema = schema.schema,
- onAccept = {}
- )
- }
- }
- }
-
- onNodeWithText("Taken up by the group into the list.").assertIsDisplayed()
- onNodeWithText("Revise the list's entry").assertIsDisplayed()
- onAllNodesWithText("Accept this suggestion").assertCountEquals(0)
- }
-
- private fun suggestion(
- title: String,
- createdAt: Long,
- isCurated: Boolean,
- suggester: Profile? = null
- ): CuratedSuggestion {
- val id = createdAt.toString().padStart(64, 'e')
- val identifier = title.lowercase().replace(' ', '-')
-
- return CuratedSuggestion(
- entry = CuratedEntry(
- kind = CuratedSchemaEvent.SUGGESTION_KIND,
- id = id,
- pubKey = suggester?.publicKey ?: bob,
- createdAt = createdAt,
- identifier = identifier,
- title = title,
- values = mapOf(
- "identifier" to listOf(identifier),
- "title" to listOf(title),
- "year" to listOf("2018"),
- "type" to listOf("documentary"),
- "watchUrl" to listOf("https://example.com/watch"),
- "review" to listOf("Follows the early community."),
- )
- ),
- event = NostrEvent(
- id = id,
- pubKey = suggester?.publicKey ?: bob,
- kind = CuratedSchemaEvent.SUGGESTION_KIND,
- tags = arrayOf(arrayOf("d", identifier), arrayOf("title", title)),
- content = "Follows the early community.",
- sig = "b".repeat(128),
- createdAt = Instant.fromEpochSeconds(createdAt)
- ),
- suggester = suggester,
- isCurated = isCurated
- )
- }
-
- private fun state(
- suggestions: List,
- isAsking: Boolean = false
- ) = CuratedSuggestionListUIState.Loaded(
- localChatRoom = LocalChatRoom(
- chatRoom = ChatRoom(
- id = chatRoomId,
- userPublicKey = ada,
- subject = "Translation room",
- description = null,
- initialGiftWrapPayloadId = "sdfaer",
- mlsGroupState = "state"
- )
- ),
- schema = schema,
- relays = listOf("wss://relay.one.example"),
- suggestions = suggestions,
- isAsking = isAsking
- )
-
- /**
- * The screen at a phone's width and a window tall enough to compose the
- * whole of it. The state is passed in, so the NO_OP repositories are never
- * asked for anything: `initiate` only runs for a screen that arrives loading.
- */
- private fun render(
- uiState: CuratedSuggestionListUIState,
- assertions: ComposeUiTest.() -> Unit
- ) = runDesktopComposeUiTest(width = 400, height = 2000) {
- setContent {
- MantraTheme {
- ProvideSnackbarHost {
- Box(modifier = Modifier.fillMaxSize()) {
- CuratedSuggestionListScreen(
- activeUserPublicKey = ada,
- chatRoomId = chatRoomId,
- relayHint = null,
- identifier = "films",
- initialCuratedSuggestionListUIState = uiState,
- nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
- chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
- onNavigateBack = {},
- onNavigateToRoute = {}
- )
- }
- }
- }
- }
-
- assertions()
- }
-}
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreenJvmTest.kt
deleted file mode 100644
index 5afdce93..00000000
--- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreenJvmTest.kt
+++ /dev/null
@@ -1,267 +0,0 @@
-package press.mantra.compose.ui.composable
-
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.test.ComposeUiTest
-import androidx.compose.ui.test.ExperimentalTestApi
-import androidx.compose.ui.test.assertIsDisplayed
-import androidx.compose.ui.test.assertIsNotEnabled
-import androidx.compose.ui.test.assertIsOn
-import androidx.compose.ui.test.assertIsSelected
-import androidx.compose.ui.test.getBoundsInRoot
-import androidx.compose.ui.test.onNodeWithContentDescription
-import androidx.compose.ui.test.onNodeWithText
-import androidx.compose.ui.test.performClick
-import androidx.compose.ui.test.runDesktopComposeUiTest
-import press.mantra.compose.database.model.ChatRoom
-import press.mantra.compose.database.model.GroupSignedEvent
-import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.extensions.hexToNpubHrp
-import press.mantra.compose.nostr.GroupCuratedSchema
-import press.mantra.compose.nostr.curated.CuratedField
-import press.mantra.compose.nostr.curated.CuratedFieldConfig
-import press.mantra.compose.nostr.curated.CuratedFieldType
-import press.mantra.compose.nostr.curated.CuratedSchema
-import press.mantra.compose.nostr.curated.CuratedSchemaEvent
-import press.mantra.compose.nostr.curated.CuratedVisibility
-import press.mantra.compose.repository.ChatRepository
-import press.mantra.compose.repository.FrostSigningRepository
-import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost
-import press.mantra.compose.ui.theme.MantraTheme
-import press.mantra.compose.ui.view.model.EditGroupCuratedSchemaViewModel
-import press.mantra.compose.ui.view.state.EditGroupCuratedSchemaUIState
-import kotlin.test.Test
-import kotlin.test.assertTrue
-import kotlin.time.Instant
-
-/**
- * What the schema editor puts in front of a member, in the two states it opens in.
- *
- * **A new list starts filled in.** The two fields every list has and the group's
- * own relays are on the form before anything is typed, because they are what the
- * group will sign whether or not the member adds to them -- and a form that
- * showed an empty list and then signed two fields would be lying about what it
- * proposed.
- *
- * **An existing list opens as an edit.** Its identifier is locked and the form
- * says why, its rules show which fields they name, and the suggesters a closed
- * list admits are there to be read and removed. The rows are what a member
- * checks before proposing, and each is an intention a later change to the
- * `LazyColumn` could drop without anything else noticing.
- *
- * **The sheet offers a type its own settings.** A url field gets "https only"
- * and a year field does not, because a setting the event gives no meaning to
- * would be written into the schema and mean nothing.
- */
-@OptIn(ExperimentalTestApi::class)
-class EditGroupCuratedSchemaScreenJvmTest {
-
- private val chatRoomId = "d4c3b2a1".repeat(8)
-
- private val suggester = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860"
-
- private val films = CuratedSchema(
- identifier = "films",
- title = "Film suggestion",
- name = "Films worth translating",
- description = "Films the group would subtitle, with a link to watch each one.",
- visibility = CuratedVisibility.Closed,
- fields = listOf(
- CuratedField.IDENTIFIER,
- CuratedField.TITLE,
- CuratedField(
- name = "watchUrl",
- type = CuratedFieldType.Url,
- isRequired = false,
- label = "Where to watch",
- config = CuratedFieldConfig(tag = "r", marker = "watch", max = 500),
- ),
- CuratedField(
- name = "imdbUrl",
- type = CuratedFieldType.Url,
- isRequired = false,
- label = "IMDb page",
- config = CuratedFieldConfig(tag = "r", marker = "imdb", max = 500),
- ),
- ),
- requireAny = listOf(listOf("watchUrl", "imdbUrl")),
- suggesters = listOf(suggester),
- relays = listOf("wss://relay.one.example"),
- )
-
- @Test
- fun `a new list starts with the two fields every list has and the group's relays`() = render(
- state(existing = null, defaultRelays = listOf("wss://relay.one.example/"))
- ) {
- // By their supporting line: "Identifier" is also the label over the
- // identifier field above, and the row is the one that says what it is.
- onNodeWithText("identifier · Token · Required · Derived").assertIsDisplayed()
- onNodeWithText("title · Text · Required").assertIsDisplayed()
- onNodeWithText("wss://relay.one.example/").assertIsDisplayed()
-
- onNodeWithText("Public").assertIsSelected()
- // A public list admits anyone, so there is no list of who it admits.
- onNodeWithText("Suggesters").assertDoesNotExist()
-
- onNodeWithText("Add field").assertIsDisplayed()
- onNodeWithText("Add rule").assertIsDisplayed()
- onNodeWithText("Propose schema").assertIsDisplayed()
-
- // Nothing locks a new identifier: the member is choosing it.
- onNodeWithText("The identifier names this list in every reply", substring = true)
- .assertDoesNotExist()
- }
-
- @Test
- fun `an existing list opens as an edit with its identifier locked`() = render(
- state(existing = existing(films))
- ) {
- onNodeWithText("films").assertIsDisplayed()
- onNodeWithText("The identifier names this list in every reply", substring = true)
- .assertIsDisplayed()
-
- onNodeWithText("Closed").assertIsSelected()
-
- // Every field, in the order it will be written.
- val identifier = onNodeWithText("identifier · Token · Required · Derived").getBoundsInRoot().top
- val watch = onNodeWithText("Where to watch").getBoundsInRoot().top
- val imdb = onNodeWithText("IMDb page").getBoundsInRoot().top
- assertTrue(identifier < watch && watch < imdb, "the fields were drawn out of order")
-
- // The rule names its fields, and the suggesters are readable as npubs.
- onNodeWithText("At least one of").assertIsDisplayed()
- onNodeWithText("Suggesters").assertIsDisplayed()
- onNodeWithText(suggester.hexToNpubHrp()).assertIsDisplayed()
- onNodeWithText("wss://relay.one.example").assertIsDisplayed()
- }
-
- @Test
- fun `a member holding no share of the key is told so and the button is inert`() = render(
- state(existing = existing(films), canSign = false)
- ) {
- onNodeWithText("This group has no shared key, so it cannot sign a curated schema.", substring = true)
- .assertIsDisplayed()
- onNodeWithContentDescription("Propose schema").assertIsNotEnabled()
- }
-
- @Test
- fun `the sheet offers a type its own settings and pins a mandatory field`() {
- // A url field: https is a thing it can be told, options are not.
- renderSheet(field = films.fields[2], index = 2, isMandatory = false) {
- onNodeWithText("Edit field").assertIsDisplayed()
- onNodeWithText("Url").assertIsSelected()
- onNodeWithText("https only").assertIsDisplayed()
- onNodeWithText("Options").assertDoesNotExist()
- onNodeWithText("Minimum value").assertDoesNotExist()
- onNodeWithText("Remove field").assertIsDisplayed()
-
- // Changing the type changes what is offered.
- onNodeWithText("One of a list").performClick()
- onNodeWithText("Options").assertIsDisplayed()
- onNodeWithText("https only").assertDoesNotExist()
- }
-
- // The identifier: required whatever the switch says, and not removable.
- renderSheet(field = films.fields[0], index = 0, isMandatory = true) {
- onNodeWithContentDescription("An entry must have this field").assertIsOn()
- onNodeWithContentDescription("An entry must have this field").assertIsNotEnabled()
- onNodeWithText("Remove field").assertDoesNotExist()
- }
- }
-
- private fun existing(schema: CuratedSchema) = GroupCuratedSchema(
- signedEvent = GroupSignedEvent(
- id = "c".repeat(64),
- chatRoomId = chatRoomId,
- publicKey = chatRoomId,
- kind = CuratedSchemaEvent.KIND,
- tags = CuratedSchemaEvent.template(schema, createdAt = 1_700_000_000).tags,
- content = schema.description,
- signature = "f".repeat(128),
- createdAt = Instant.fromEpochSeconds(1_700_000_000),
- ),
- schema = schema.normalized(),
- )
-
- private fun state(
- existing: GroupCuratedSchema?,
- defaultRelays: List = emptyList(),
- canSign: Boolean = true,
- ) = EditGroupCuratedSchemaUIState.Loaded(
- localChatRoom = LocalChatRoom(
- chatRoom = ChatRoom(
- id = chatRoomId,
- userPublicKey = "a".repeat(64),
- subject = "Translation room",
- description = "A group translating hard books.",
- initialGiftWrapPayloadId = "sdfaer",
- mlsGroupState = "state"
- )
- ),
- existing = existing,
- defaultRelays = defaultRelays,
- canSign = canSign,
- )
-
- /**
- * The screen at a phone's width and a window tall enough to compose the whole
- * of it, since a `LazyColumn` does not lay out what it would not show.
- */
- private fun render(
- uiState: EditGroupCuratedSchemaUIState,
- assertions: ComposeUiTest.() -> Unit
- ) = runDesktopComposeUiTest(width = 400, height = 3000) {
- setContent {
- MantraTheme {
- ProvideSnackbarHost {
- Box(modifier = Modifier.fillMaxSize()) {
- EditGroupCuratedSchemaScreen(
- activeUserPublicKey = "a".repeat(64),
- chatRoomId = chatRoomId,
- relayHint = null,
- identifier = (uiState as? EditGroupCuratedSchemaUIState.Loaded)
- ?.existing?.identifier,
- initialEditGroupCuratedSchemaUIState = uiState,
- chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
- frostSigningRepository =
- FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
- onNavigateToRouteAndPopUpInclusive = {},
- onNavigateBack = {},
- )
- }
- }
- }
- }
-
- assertions()
- }
-
- /**
- * The sheet's content on its own: a bottom sheet is a popup with its own
- * window, which a layout test cannot reach into.
- */
- private fun renderSheet(
- field: CuratedField,
- index: Int,
- isMandatory: Boolean,
- assertions: ComposeUiTest.() -> Unit
- ) = runDesktopComposeUiTest(width = 400, height = 2000) {
- setContent {
- MantraTheme {
- ProvideSnackbarHost {
- Box(modifier = Modifier.fillMaxSize()) {
- FieldEditorSheetContent(
- edit = EditGroupCuratedSchemaViewModel.FieldEdit(index = index, field = field),
- isMandatory = isMandatory,
- onCommit = { null },
- onRemove = {}
- )
- }
- }
- }
- }
-
- assertions()
- }
-}
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupCuratedEntriesScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupCuratedEntriesScreenJvmTest.kt
deleted file mode 100644
index dc50e0b2..00000000
--- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupCuratedEntriesScreenJvmTest.kt
+++ /dev/null
@@ -1,133 +0,0 @@
-package press.mantra.compose.ui.composable
-
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.test.ComposeUiTest
-import androidx.compose.ui.test.ExperimentalTestApi
-import androidx.compose.ui.test.assertCountEquals
-import androidx.compose.ui.test.assertIsDisplayed
-import androidx.compose.ui.test.getBoundsInRoot
-import androidx.compose.ui.test.onAllNodesWithText
-import androidx.compose.ui.test.onNodeWithText
-import androidx.compose.ui.test.performClick
-import androidx.compose.ui.test.runDesktopComposeUiTest
-import press.mantra.compose.nostr.GroupCuratedEntry
-import press.mantra.compose.repository.ChatRepository
-import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.activeUserPublicKey
-import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.chatRoomId
-import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.entry
-import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute
-import press.mantra.compose.ui.composable.navigation.routes.Route
-import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost
-import press.mantra.compose.ui.theme.MantraTheme
-import press.mantra.compose.ui.view.state.GroupCuratedEntriesUIState
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertTrue
-
-/**
- * The entries the group has accepted on their own screen: one card per entry,
- * most recently signed first, each with its broadcast and nothing else --
- * there is no way in to add one here.
- */
-@OptIn(ExperimentalTestApi::class)
-class GroupCuratedEntriesScreenJvmTest {
-
- private val newest = entry("The Rise and Rise of Bitcoin", 1_700_000_900)
- private val oldest = entry("Magic Money", 1_700_000_300, suggestedBy = null)
-
- @Test
- fun `the entries are drawn most recently signed first, one card per entry`() = render(
- state(entries = listOf(newest, oldest))
- ) {
- val first = onNodeWithText("The Rise and Rise of Bitcoin").getBoundsInRoot().top
- val second = onNodeWithText("Magic Money").getBoundsInRoot().top
- assertTrue(first < second, "the entries were drawn oldest first")
-
- // Which list, what the entry is -- labelled as the schema labels it --
- // and who suggested it, where somebody did.
- onAllNodesWithText("In Films worth translating").assertCountEquals(2)
- onAllNodesWithText("Year: 2014", substring = true).assertCountEquals(2)
- onAllNodesWithText("Suggested by eebb74ab · ", substring = true).assertCountEquals(1)
- // Dated like every signed statement the group makes, once per entry.
- onAllNodesWithText("Signed by the group on", substring = true).assertCountEquals(2)
- }
-
- @Test
- fun `every accepted entry offers a broadcast under its own card`() {
- var opened: Route? = null
-
- render(state(entries = listOf(newest, oldest)), onNavigateToRoute = { opened = it }) {
- // The entry is the one event the group signs whose broadcast the
- // queue screen depends on, since that is where the "Curated" mark is
- // read back from.
- val buttons = onAllNodesWithText("Broadcast")
- buttons.assertCountEquals(2)
-
- val firstCard = onNodeWithText("The Rise and Rise of Bitcoin").getBoundsInRoot().top
- val secondCard = onNodeWithText("Magic Money").getBoundsInRoot().top
- val first = buttons[0].getBoundsInRoot().top
- val second = buttons[1].getBoundsInRoot().top
-
- assertTrue(firstCard < first && first < secondCard, "the first entry's broadcast is not under its card")
- assertTrue(secondCard < second, "the second entry's broadcast is not under its card")
-
- buttons[0].performClick()
- }
-
- assertEquals(
- BroadcastGroupSignedEventRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = null,
- signedEventId = newest.signedEvent.id
- ),
- opened
- )
- }
-
- @Test
- fun `a group that has accepted nothing says so, and offers no way to add one here`() = render(
- state(entries = emptyList())
- ) {
- onNodeWithText("This group hasn't accepted anything into a list yet.", substring = true)
- .assertIsDisplayed()
-
- // An entry is accepted from the queue on its schema's card, on the
- // schemas screen, so this screen has no button of its own.
- onAllNodesWithText("Add entry").assertCountEquals(0)
- onAllNodesWithText("Broadcast").assertCountEquals(0)
- }
-
- private fun state(entries: List) = GroupCuratedEntriesUIState.Loaded(
- localChatRoom = GroupSignedWorkFixtures.localChatRoom(),
- groupCuratedEntries = entries
- )
-
- private fun render(
- uiState: GroupCuratedEntriesUIState,
- onNavigateToRoute: (Route) -> Unit = {},
- assertions: ComposeUiTest.() -> Unit
- ) = runDesktopComposeUiTest(width = 400, height = 2000) {
- setContent {
- MantraTheme {
- ProvideSnackbarHost {
- Box(modifier = Modifier.fillMaxSize()) {
- GroupCuratedEntriesScreen(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = null,
- initialGroupCuratedEntriesUIState = uiState,
- chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
- onNavigateToRoute = onNavigateToRoute,
- onNavigateBack = {},
- )
- }
- }
- }
- }
-
- assertions()
- }
-}
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupCuratedSchemasScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupCuratedSchemasScreenJvmTest.kt
deleted file mode 100644
index 2bb92775..00000000
--- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupCuratedSchemasScreenJvmTest.kt
+++ /dev/null
@@ -1,231 +0,0 @@
-package press.mantra.compose.ui.composable
-
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.test.ComposeUiTest
-import androidx.compose.ui.test.ExperimentalTestApi
-import androidx.compose.ui.test.assertCountEquals
-import androidx.compose.ui.test.assertIsDisplayed
-import androidx.compose.ui.test.getBoundsInRoot
-import androidx.compose.ui.test.onAllNodesWithText
-import androidx.compose.ui.test.onNodeWithContentDescription
-import androidx.compose.ui.test.onNodeWithText
-import androidx.compose.ui.test.performClick
-import androidx.compose.ui.test.runDesktopComposeUiTest
-import press.mantra.compose.nostr.GroupCuratedSchema
-import press.mantra.compose.repository.ChatRepository
-import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.activeUserPublicKey
-import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.chatRoomId
-import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.schema
-import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute
-import press.mantra.compose.ui.composable.navigation.routes.CuratedSuggestionListRoute
-import press.mantra.compose.ui.composable.navigation.routes.EditGroupCuratedSchemaRoute
-import press.mantra.compose.ui.composable.navigation.routes.Route
-import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost
-import press.mantra.compose.ui.theme.MantraTheme
-import press.mantra.compose.ui.view.state.GroupCuratedSchemasUIState
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertNull
-import kotlin.test.assertTrue
-
-/**
- * The schemas of the lists the group curates on their own screen: one card per
- * list, most recently signed first, each with its queue and its broadcast, and
- * two ways into the editor -- the card for a revision, the button for a new
- * list -- for a member holding a share.
- */
-@OptIn(ExperimentalTestApi::class)
-class GroupCuratedSchemasScreenJvmTest {
-
- private val films = schema("films", "Films worth translating", 1_700_000_900)
- private val books = schema("books", "Books worth translating", 1_700_000_000)
-
- @Test
- fun `the schemas are drawn most recently signed first, one card per list`() = render(
- state(schemas = listOf(films, books))
- ) {
- val newest = onNodeWithText("Films worth translating").getBoundsInRoot().top
- val oldest = onNodeWithText("Books worth translating").getBoundsInRoot().top
- assertTrue(newest < oldest, "the schemas were drawn oldest first")
-
- // What an entry asks for, and who may suggest one.
- onAllNodesWithText("Fields: Identifier, Title", substring = true).assertCountEquals(2)
- onAllNodesWithText("Public · ", substring = true).assertCountEquals(2)
- onNodeWithText("Add schema").assertIsDisplayed()
- }
-
- @Test
- fun `every schema offers its own queue, under its own card`() {
- var opened: Route? = null
-
- render(state(schemas = listOf(films, books)), onNavigateToRoute = { opened = it }) {
- // One per list, because each list has its own queue -- and under the
- // card it belongs to rather than once for the screen, since a button
- // between two cards would say nothing about which it opened.
- val buttons = onAllNodesWithText("View suggestions")
- buttons.assertCountEquals(2)
-
- val filmsCard = onNodeWithText("Films worth translating").getBoundsInRoot().top
- val booksCard = onNodeWithText("Books worth translating").getBoundsInRoot().top
- val first = buttons[0].getBoundsInRoot().top
- val second = buttons[1].getBoundsInRoot().top
-
- assertTrue(filmsCard < first && first < booksCard, "the first queue button is not under its card")
- assertTrue(booksCard < second, "the second queue button is not under its card")
-
- buttons[1].performClick()
- }
-
- assertEquals(
- CuratedSuggestionListRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = null,
- identifier = "books"
- ),
- opened
- )
- }
-
- @Test
- fun `every schema offers a broadcast, beside its queue`() {
- var opened: Route? = null
-
- render(state(schemas = listOf(films, books)), onNavigateToRoute = { opened = it }) {
- val buttons = onAllNodesWithText("Broadcast")
- buttons.assertCountEquals(2)
-
- val filmsCard = onNodeWithText("Films worth translating").getBoundsInRoot().top
- val booksCard = onNodeWithText("Books worth translating").getBoundsInRoot().top
- assertTrue(filmsCard < buttons[0].getBoundsInRoot().top, "the first broadcast is not under its card")
- assertTrue(booksCard < buttons[1].getBoundsInRoot().top, "the second broadcast is not under its card")
-
- buttons[0].performClick()
- }
-
- assertEquals(
- BroadcastGroupSignedEventRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = null,
- signedEventId = films.signedEvent.id
- ),
- opened
- )
- }
-
- @Test
- fun `a card opens the editor on its own schema, for a member holding a share of the key`() {
- var opened: Route? = null
-
- render(state(schemas = listOf(films, books)), onNavigateToRoute = { opened = it }) {
- onNodeWithText("Books worth translating").performClick()
- }
-
- assertEquals(
- EditGroupCuratedSchemaRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = null,
- identifier = "books"
- ),
- opened
- )
- }
-
- @Test
- fun `the button opens the editor on no schema at all`() {
- var opened: Route? = null
-
- render(state(schemas = listOf(films)), onNavigateToRoute = { opened = it }) {
- onNodeWithText("Add schema").performClick()
- }
-
- assertEquals(
- EditGroupCuratedSchemaRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = null,
- identifier = null
- ),
- opened
- )
- }
-
- @Test
- fun `a member holding no share of the key reads the schemas and is offered no editor`() {
- var opened: Route? = null
-
- render(state(schemas = listOf(films), canSign = false), onNavigateToRoute = { opened = it }) {
- // A schema exists to be read by people who were never in the room, so
- // a member without a share reads it like anybody else and simply
- // cannot propose a revision -- neither a new one nor an edit.
- onNodeWithText("Films worth translating").assertIsDisplayed()
- onNodeWithText("Add schema").assertDoesNotExist()
- onNodeWithContentDescription("Edit schema").assertDoesNotExist()
-
- // The queue is the one thing on the screen the group did not write,
- // and reading it takes no share of the key.
- onNodeWithText("View suggestions").assertIsDisplayed()
-
- // A card that cannot be opened goes nowhere when tapped.
- onNodeWithText("Films worth translating").performClick()
- }
-
- assertNull(opened, "a card with no editor behind it opened something")
- }
-
- @Test
- fun `a group that curates nothing says so, and offers to add a schema`() {
- var opened: Route? = null
-
- render(state(schemas = emptyList()), onNavigateToRoute = { opened = it }) {
- onNodeWithText("This group hasn't published a curated schema yet.", substring = true)
- .assertIsDisplayed()
- onAllNodesWithText("View suggestions").assertCountEquals(0)
- onNodeWithText("Add schema").assertIsDisplayed().performClick()
- }
-
- assertEquals(
- EditGroupCuratedSchemaRoute(activeUserPublicKey, chatRoomId, relayHint = null, identifier = null),
- opened
- )
- }
-
- private fun state(
- schemas: List,
- canSign: Boolean = true
- ) = GroupCuratedSchemasUIState.Loaded(
- localChatRoom = GroupSignedWorkFixtures.localChatRoom(),
- groupCuratedSchemas = schemas,
- canSign = canSign
- )
-
- private fun render(
- uiState: GroupCuratedSchemasUIState,
- onNavigateToRoute: (Route) -> Unit = {},
- assertions: ComposeUiTest.() -> Unit
- ) = runDesktopComposeUiTest(width = 400, height = 2000) {
- setContent {
- MantraTheme {
- ProvideSnackbarHost {
- Box(modifier = Modifier.fillMaxSize()) {
- GroupCuratedSchemasScreen(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = null,
- initialGroupCuratedSchemasUIState = uiState,
- chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
- onNavigateToRoute = onNavigateToRoute,
- onNavigateBack = {},
- )
- }
- }
- }
- }
-
- assertions()
- }
-}
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileScreenJvmTest.kt
deleted file mode 100644
index 8f94dd16..00000000
--- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileScreenJvmTest.kt
+++ /dev/null
@@ -1,153 +0,0 @@
-package press.mantra.compose.ui.composable
-
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.test.ComposeUiTest
-import androidx.compose.ui.test.ExperimentalTestApi
-import androidx.compose.ui.test.assertCountEquals
-import androidx.compose.ui.test.assertIsDisplayed
-import androidx.compose.ui.test.getBoundsInRoot
-import androidx.compose.ui.test.onAllNodesWithText
-import androidx.compose.ui.test.onNodeWithText
-import androidx.compose.ui.test.performClick
-import androidx.compose.ui.test.runDesktopComposeUiTest
-import press.mantra.compose.nostr.GroupNostrProfile
-import press.mantra.compose.repository.ChatRepository
-import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.activeUserPublicKey
-import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.chatRoomId
-import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.profile
-import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute
-import press.mantra.compose.ui.composable.navigation.routes.EditGroupNostrProfileRoute
-import press.mantra.compose.ui.composable.navigation.routes.Route
-import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost
-import press.mantra.compose.ui.theme.MantraTheme
-import press.mantra.compose.ui.view.state.GroupNostrProfileUIState
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertTrue
-
-/**
- * The group's nostr profile on its own screen: what is drawn, what the
- * broadcast under it sends, and who is offered the editor.
- */
-@OptIn(ExperimentalTestApi::class)
-class GroupNostrProfileScreenJvmTest {
-
- @Test
- fun `the profile is drawn whole, with its broadcast under it`() {
- var opened: Route? = null
-
- render(state(groupNostrProfile = profile), onNavigateToRoute = { opened = it }) {
- onNodeWithText("Translation collective").assertIsDisplayed()
- onNodeWithText("group@example.com").assertIsDisplayed()
- onNodeWithText("A group translating hard books into Sesotho.").assertIsDisplayed()
- onAllNodesWithText("Signed by the group on", substring = true).assertCountEquals(1)
-
- // One, under the card it sends, and it sends the profile's event.
- val buttons = onAllNodesWithText("Broadcast")
- buttons.assertCountEquals(1)
- val card = onNodeWithText("group@example.com").getBoundsInRoot().top
- assertTrue(card < buttons[0].getBoundsInRoot().top, "the broadcast is not under the card")
-
- buttons[0].performClick()
- }
-
- assertEquals(
- BroadcastGroupSignedEventRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = null,
- signedEventId = profile.signedEvent.id
- ),
- opened
- )
- }
-
- @Test
- fun `a member holding a share of the key is offered the editor`() {
- var opened: Route? = null
-
- render(state(groupNostrProfile = profile), onNavigateToRoute = { opened = it }) {
- onNodeWithText("Edit Nostr profile").assertIsDisplayed().performClick()
- }
-
- assertEquals(
- EditGroupNostrProfileRoute(activeUserPublicKey, chatRoomId, relayHint = null),
- opened
- )
- }
-
- @Test
- fun `a member holding no share of the key reads the profile and is offered no edit`() = render(
- state(groupNostrProfile = profile, canSign = false)
- ) {
- // The profile is worth reading whoever is looking; proposing one needs a
- // share, and `proposeSigningBatch` throws without one. The broadcast
- // stays: the signature is the group's whoever repeats it.
- onNodeWithText("Translation collective").assertIsDisplayed()
- onNodeWithText("Edit Nostr profile").assertDoesNotExist()
- onAllNodesWithText("Broadcast").assertCountEquals(1)
- }
-
- @Test
- fun `a group that has said nothing says so, and offers to say something`() = render(
- state(groupNostrProfile = null)
- ) {
- onNodeWithText("This group hasn't said anything about itself on Nostr yet.")
- .assertIsDisplayed()
- onNodeWithText("Edit Nostr profile").assertIsDisplayed()
-
- // Nothing signed, so nothing to send.
- onAllNodesWithText("Broadcast").assertCountEquals(0)
- }
-
- @Test
- fun `a group that has said nothing offers no editor to a member holding no share`() = render(
- state(groupNostrProfile = null, canSign = false)
- ) {
- onNodeWithText("This group hasn't said anything about itself on Nostr yet.")
- .assertIsDisplayed()
- onNodeWithText("Edit Nostr profile").assertDoesNotExist()
- }
-
- private fun state(
- groupNostrProfile: GroupNostrProfile?,
- canSign: Boolean = true
- ) = GroupNostrProfileUIState.Loaded(
- localChatRoom = GroupSignedWorkFixtures.localChatRoom(),
- groupNostrProfile = groupNostrProfile,
- canSign = canSign
- )
-
- /**
- * The screen at a phone's width. The state is passed in, so the NO_OP
- * repository is never asked for anything: `initiate` only runs for a screen
- * that arrives loading.
- */
- private fun render(
- uiState: GroupNostrProfileUIState,
- onNavigateToRoute: (Route) -> Unit = {},
- assertions: ComposeUiTest.() -> Unit
- ) = runDesktopComposeUiTest(width = 400, height = 2000) {
- setContent {
- MantraTheme {
- ProvideSnackbarHost {
- Box(modifier = Modifier.fillMaxSize()) {
- GroupNostrProfileScreen(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = null,
- initialGroupNostrProfileUIState = uiState,
- chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
- onNavigateToRoute = onNavigateToRoute,
- onNavigateBack = {},
- )
- }
- }
- }
- }
-
- assertions()
- }
-}
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupPostsScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupPostsScreenJvmTest.kt
deleted file mode 100644
index 15a1af7a..00000000
--- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupPostsScreenJvmTest.kt
+++ /dev/null
@@ -1,167 +0,0 @@
-package press.mantra.compose.ui.composable
-
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.test.ComposeUiTest
-import androidx.compose.ui.test.ExperimentalTestApi
-import androidx.compose.ui.test.assertCountEquals
-import androidx.compose.ui.test.assertIsDisplayed
-import androidx.compose.ui.test.getBoundsInRoot
-import androidx.compose.ui.test.onAllNodesWithText
-import androidx.compose.ui.test.onNodeWithText
-import androidx.compose.ui.test.performClick
-import androidx.compose.ui.test.runDesktopComposeUiTest
-import press.mantra.compose.nostr.GroupNostrProfile
-import press.mantra.compose.nostr.GroupPost
-import press.mantra.compose.repository.ChatRepository
-import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.activeUserPublicKey
-import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.chatRoomId
-import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.post
-import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.profile
-import press.mantra.compose.ui.composable.navigation.routes.AddGroupPostRoute
-import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute
-import press.mantra.compose.ui.composable.navigation.routes.Route
-import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost
-import press.mantra.compose.ui.theme.MantraTheme
-import press.mantra.compose.ui.view.state.GroupPostsUIState
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertTrue
-
-/**
- * The group's posts on their own screen: newest first and whole, under the
- * group's name, each with its broadcast, and the composer for a member holding
- * a share.
- */
-@OptIn(ExperimentalTestApi::class)
-class GroupPostsScreenJvmTest {
-
- @Test
- fun `the posts are drawn newest first, under the group's name`() = render(
- state(posts = listOf(post("The second thing", 1_700_000_900), post("The first thing", 1_700_000_000)))
- ) {
- // Newest first, and whole rather than previewed -- a post is the one thing
- // the group signs that exists to be read.
- val newest = onNodeWithText("The second thing").getBoundsInRoot().top
- val oldest = onNodeWithText("The first thing").getBoundsInRoot().top
- assertTrue(newest < oldest, "the posts were drawn oldest first")
-
- // Under the group's name, not the member's. The whole point of signing a
- // post as the group is that no member can be blamed for it. Once per
- // post: there is no profile card on this screen to add a third.
- onAllNodesWithText("Translation collective").assertCountEquals(2)
- onNodeWithText("Add post").assertIsDisplayed()
- }
-
- @Test
- fun `a group that has not described itself bylines its posts with the key`() = render(
- state(posts = listOf(post("Something the group said", 1_700_000_000)), groupNostrProfile = null)
- ) {
- onNodeWithText("Something the group said").assertIsDisplayed()
- onNodeWithText(chatRoomId.take(16).chunked(8).joinToString(" ")).assertIsDisplayed()
- }
-
- @Test
- fun `every post offers a broadcast under its card`() {
- var opened: Route? = null
-
- render(
- state(posts = listOf(post("The second thing", 1_700_000_900), post("The first thing", 1_700_000_000))),
- onNavigateToRoute = { opened = it }
- ) {
- val buttons = onAllNodesWithText("Broadcast")
- buttons.assertCountEquals(2)
-
- val newest = onNodeWithText("The second thing").getBoundsInRoot().top
- val oldest = onNodeWithText("The first thing").getBoundsInRoot().top
- val first = buttons[0].getBoundsInRoot().top
- val second = buttons[1].getBoundsInRoot().top
-
- assertTrue(newest < first && first < oldest, "the first post's broadcast is not under its card")
- assertTrue(oldest < second, "the second post's broadcast is not under its card")
-
- buttons[1].performClick()
- }
-
- assertEquals(
- BroadcastGroupSignedEventRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = null,
- signedEventId = post("The first thing", 1_700_000_000).signedEvent.id
- ),
- opened
- )
- }
-
- @Test
- fun `a group that has posted nothing says so, and offers to post`() {
- var opened: Route? = null
-
- render(state(posts = emptyList()), onNavigateToRoute = { opened = it }) {
- onNodeWithText("This group hasn't posted anything yet.").assertIsDisplayed()
- onAllNodesWithText("Broadcast").assertCountEquals(0)
- onNodeWithText("Add post").assertIsDisplayed().performClick()
- }
-
- assertEquals(AddGroupPostRoute(activeUserPublicKey, chatRoomId, relayHint = null), opened)
- }
-
- @Test
- fun `a member holding no share of the key reads the posts and is offered no composer`() = render(
- state(posts = listOf(post("Something the group said", 1_700_000_000)), canSign = false)
- ) {
- // A post is the one thing here that is meant to be read by people who
- // were not in the room, so a member without a share reads it like
- // anybody else and simply cannot propose the next one.
- onNodeWithText("Something the group said").assertIsDisplayed()
- onNodeWithText("Add post").assertDoesNotExist()
- onAllNodesWithText("Broadcast").assertCountEquals(1)
- }
-
- @Test
- fun `a group that has posted nothing offers no composer to a member holding no share`() = render(
- state(posts = emptyList(), canSign = false)
- ) {
- onNodeWithText("This group hasn't posted anything yet.").assertIsDisplayed()
- onNodeWithText("Add post").assertDoesNotExist()
- }
-
- private fun state(
- posts: List,
- groupNostrProfile: GroupNostrProfile? = profile,
- canSign: Boolean = true
- ) = GroupPostsUIState.Loaded(
- localChatRoom = GroupSignedWorkFixtures.localChatRoom(),
- groupPosts = posts,
- groupNostrProfile = groupNostrProfile,
- canSign = canSign
- )
-
- private fun render(
- uiState: GroupPostsUIState,
- onNavigateToRoute: (Route) -> Unit = {},
- assertions: ComposeUiTest.() -> Unit
- ) = runDesktopComposeUiTest(width = 400, height = 2000) {
- setContent {
- MantraTheme {
- ProvideSnackbarHost {
- Box(modifier = Modifier.fillMaxSize()) {
- GroupPostsScreen(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = null,
- initialGroupPostsUIState = uiState,
- chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
- onNavigateToRoute = onNavigateToRoute,
- onNavigateBack = {},
- )
- }
- }
- }
- }
-
- assertions()
- }
-}
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupRelaysScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupRelaysScreenJvmTest.kt
deleted file mode 100644
index 90b42888..00000000
--- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupRelaysScreenJvmTest.kt
+++ /dev/null
@@ -1,139 +0,0 @@
-package press.mantra.compose.ui.composable
-
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.test.ComposeUiTest
-import androidx.compose.ui.test.ExperimentalTestApi
-import androidx.compose.ui.test.assertCountEquals
-import androidx.compose.ui.test.assertIsDisplayed
-import androidx.compose.ui.test.getBoundsInRoot
-import androidx.compose.ui.test.onAllNodesWithText
-import androidx.compose.ui.test.onNodeWithText
-import androidx.compose.ui.test.performClick
-import androidx.compose.ui.test.runDesktopComposeUiTest
-import press.mantra.compose.nostr.GroupRelaySet
-import press.mantra.compose.repository.ChatRepository
-import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.activeUserPublicKey
-import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.chatRoomId
-import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.relaySignedEvent
-import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.signedRelayLists
-import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute
-import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute
-import press.mantra.compose.ui.composable.navigation.routes.Route
-import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost
-import press.mantra.compose.ui.theme.MantraTheme
-import press.mantra.compose.ui.view.state.GroupRelaysUIState
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertTrue
-
-/**
- * The group's relay lists on their own screen: a row per set whatever the
- * group has said, a broadcast under each list it has agreed, and the editor
- * for a member holding a share.
- */
-@OptIn(ExperimentalTestApi::class)
-class GroupRelaysScreenJvmTest {
-
- @Test
- fun `every set gets a row, and the two absences read differently`() = render(state()) {
- onNodeWithText("General").assertIsDisplayed()
- onNodeWithText("relay.one.example").assertIsDisplayed()
- onNodeWithText("Messages").assertIsDisplayed()
- onNodeWithText("Search").assertIsDisplayed()
- onNodeWithText("Blocked").assertIsDisplayed()
- // Two of them, because Messages and Search are both unagreed -- and the
- // count is the assertion: a row per set, not a row per set the group has
- // got round to. The blocked list was agreed empty, which is a decision
- // and reads as one.
- onAllNodesWithText("Not set yet").assertCountEquals(2)
- onNodeWithText("No relays in this list").assertIsDisplayed()
- }
-
- @Test
- fun `every agreed list offers a broadcast under its row, the withdrawn one included`() {
- var opened: Route? = null
-
- render(state(), onNavigateToRoute = { opened = it }) {
- // The two the group has agreed -- the withdrawn one included, since a
- // withdrawal is a statement relays still holding the old list need to
- // hear -- and not the two it has never agreed, which are not events.
- val buttons = onAllNodesWithText("Broadcast")
- buttons.assertCountEquals(2)
-
- val general = onNodeWithText("General").getBoundsInRoot().top
- val blocked = onNodeWithText("Blocked").getBoundsInRoot().top
- val first = buttons[0].getBoundsInRoot().top
- val second = buttons[1].getBoundsInRoot().top
-
- assertTrue(general < first && first < blocked, "the general list's broadcast is not under its row")
- assertTrue(blocked < second, "the blocked list's broadcast is not under its row")
-
- buttons[1].performClick()
- }
-
- assertEquals(
- BroadcastGroupSignedEventRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = null,
- signedEventId = relaySignedEvent(GroupRelaySet.Blocked).id
- ),
- opened
- )
- }
-
- @Test
- fun `a member holding a share of the key is offered the editor`() {
- var opened: Route? = null
-
- render(state(), onNavigateToRoute = { opened = it }) {
- onNodeWithText("Edit relays").assertIsDisplayed().performClick()
- }
-
- assertEquals(EditGroupRelaysRoute(activeUserPublicKey, chatRoomId, relayHint = null), opened)
- }
-
- @Test
- fun `a member holding no share of the key reads the relays and is offered no edit`() = render(
- state(canSign = false)
- ) {
- onNodeWithText("General").assertIsDisplayed()
- onNodeWithText("relay.one.example").assertIsDisplayed()
- onNodeWithText("Edit relays").assertDoesNotExist()
- onAllNodesWithText("Broadcast").assertCountEquals(2)
- }
-
- private fun state(canSign: Boolean = true) = GroupRelaysUIState.Loaded(
- localChatRoom = GroupSignedWorkFixtures.localChatRoom(),
- groupRelayLists = signedRelayLists(),
- canSign = canSign
- )
-
- private fun render(
- uiState: GroupRelaysUIState,
- onNavigateToRoute: (Route) -> Unit = {},
- assertions: ComposeUiTest.() -> Unit
- ) = runDesktopComposeUiTest(width = 400, height = 2000) {
- setContent {
- MantraTheme {
- ProvideSnackbarHost {
- Box(modifier = Modifier.fillMaxSize()) {
- GroupRelaysScreen(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = null,
- initialGroupRelaysUIState = uiState,
- chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
- onNavigateToRoute = onNavigateToRoute,
- onNavigateBack = {},
- )
- }
- }
- }
- }
-
- assertions()
- }
-}
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupSignedWorkFixtures.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupSignedWorkFixtures.kt
deleted file mode 100644
index fae7c63a..00000000
--- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupSignedWorkFixtures.kt
+++ /dev/null
@@ -1,212 +0,0 @@
-package press.mantra.compose.ui.composable
-
-import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
-import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
-import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
-import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
-import press.mantra.compose.database.model.ChatRoom
-import press.mantra.compose.database.model.GroupKeyState
-import press.mantra.compose.database.model.GroupSignedEvent
-import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.nostr.GroupCuratedEntry
-import press.mantra.compose.nostr.GroupCuratedSchema
-import press.mantra.compose.nostr.GroupNostrProfile
-import press.mantra.compose.nostr.GroupPost
-import press.mantra.compose.nostr.GroupRelay
-import press.mantra.compose.nostr.GroupRelayList
-import press.mantra.compose.nostr.GroupRelaySet
-import press.mantra.compose.nostr.curated.CuratedEntry
-import press.mantra.compose.nostr.curated.CuratedEntryEvent
-import press.mantra.compose.nostr.curated.CuratedField
-import press.mantra.compose.nostr.curated.CuratedFieldType
-import press.mantra.compose.nostr.curated.CuratedSchema
-import press.mantra.compose.nostr.curated.CuratedSchemaEvent
-import press.mantra.compose.nostr.curated.CuratedSuggestionRef
-import press.mantra.compose.nostr.curated.CuratedVisibility
-import kotlin.time.Instant
-
-/**
- * One group's signed work, for the six screens that show it: the group's own,
- * where a row names each kind, and the five behind those rows.
- *
- * Everything here is built rather than read out of a signed event. The reading
- * checks a real signature, and these tests are about the screens rather than
- * about that -- so nothing here verifies, and none of the keys derive.
- */
-internal object GroupSignedWorkFixtures {
-
- val chatRoomId = "d4c3b2a1".repeat(8)
-
- val activeUserPublicKey = "a".repeat(64)
-
- val suggester = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860"
-
- /**
- * The room, as this device names it.
- *
- * The subject is deliberately not the profile's name: the room's subject is
- * this device's name for it and the profile is the group's own, and a test
- * that used one string for both could not tell them apart.
- */
- fun localChatRoom(mlsGroupState: String? = "state") = LocalChatRoom(
- chatRoom = ChatRoom(
- id = chatRoomId,
- userPublicKey = activeUserPublicKey,
- subject = "Translation room",
- description = "A group translating hard books.",
- initialGiftWrapPayloadId = "sdfaer",
- mlsGroupState = mlsGroupState
- )
- )
-
- /**
- * A key state, so the signing key row the identity block sits under is on
- * the group's screen to be measured against. Not a real key: nothing here
- * derives.
- */
- val groupKeyState = GroupKeyState(
- chatRoomId = chatRoomId,
- dkgSessionId = "c".repeat(64),
- thresholdPublicKey = "02".padEnd(66, 'a'),
- derivationPath = "m/9420/0/0",
- announcedBy = chatRoomId,
- announcedAt = Instant.fromEpochSeconds(1_700_000_000)
- )
-
- val profile = GroupNostrProfile(
- signedEvent = GroupSignedEvent(
- id = "e".repeat(64),
- chatRoomId = chatRoomId,
- publicKey = chatRoomId,
- kind = MetadataEvent.KIND,
- tags = emptyArray(),
- content = "",
- signature = "f".repeat(128),
- createdAt = Instant.fromEpochSeconds(1_700_000_000)
- ),
- metadata = UserMetadata().apply {
- displayName = "Translation collective"
- about = "A group translating hard books into Sesotho."
- nip05 = "group@example.com"
- }
- )
-
- /**
- * Two agreed sets and two absences, which is the only fixture that exercises
- * the three things a relay row can say: a list, "not set yet", and "no
- * relays".
- */
- fun signedRelayLists(): List = GroupRelaySet.entries.map { set ->
- when (set) {
- GroupRelaySet.General -> GroupRelayList(
- set = set,
- signedEvent = relaySignedEvent(set),
- relays = listOf(GroupRelay(RelayUrlNormalizer.normalize("wss://relay.one.example")))
- )
- // Agreed and empty: the group withdrew its list rather than never
- // having had one.
- GroupRelaySet.Blocked -> GroupRelayList(
- set = set,
- signedEvent = relaySignedEvent(set),
- relays = emptyList()
- )
- else -> GroupRelayList(set = set, signedEvent = null, relays = emptyList())
- }
- }
-
- fun relaySignedEvent(set: GroupRelaySet) = GroupSignedEvent(
- id = "a" + set.kind.toString().padStart(5, '0') + "0".repeat(58),
- chatRoomId = chatRoomId,
- publicKey = chatRoomId,
- kind = set.kind,
- tags = emptyArray(),
- content = "",
- signature = "f".repeat(128),
- createdAt = Instant.fromEpochSeconds(1_700_000_000)
- )
-
- fun post(content: String, createdAt: Long) = GroupPost(
- signedEvent = GroupSignedEvent(
- id = createdAt.toString().padStart(64, 'b'),
- chatRoomId = chatRoomId,
- publicKey = chatRoomId,
- kind = TextNoteEvent.KIND,
- tags = emptyArray(),
- content = content,
- signature = "f".repeat(128),
- createdAt = Instant.fromEpochSeconds(createdAt)
- )
- )
-
- /** A list the group curates, as the schema it signed for it. */
- fun schema(identifier: String, name: String, createdAt: Long) = GroupCuratedSchema(
- signedEvent = GroupSignedEvent(
- id = createdAt.toString().padStart(64, 'c'),
- chatRoomId = chatRoomId,
- publicKey = chatRoomId,
- kind = CuratedSchemaEvent.KIND,
- tags = emptyArray(),
- content = "",
- signature = "f".repeat(128),
- createdAt = Instant.fromEpochSeconds(createdAt)
- ),
- schema = CuratedSchema(
- identifier = identifier,
- title = "$name suggestion",
- name = name,
- description = "Things the group would translate, with a link to each.",
- visibility = CuratedVisibility.Public,
- fields = listOf(CuratedField.IDENTIFIER, CuratedField.TITLE),
- relays = listOf("wss://relay.one.example")
- )
- )
-
- /**
- * An entry the group accepted into the films list. The list's schema has a
- * year, so the card has a glance to give.
- */
- fun entry(
- title: String,
- createdAt: Long,
- suggestedBy: String? = suggester
- ): GroupCuratedEntry {
- val list = schema("films", "Films worth translating", 1_700_000_000).let { films ->
- films.copy(
- schema = films.schema.copy(
- fields = films.schema.fields + CuratedField("year", CuratedFieldType.Year, isRequired = false, label = "Year")
- )
- )
- }
- val identifier = title.lowercase().replace(' ', '-')
-
- return GroupCuratedEntry(
- signedEvent = GroupSignedEvent(
- id = createdAt.toString().padStart(64, 'd'),
- chatRoomId = chatRoomId,
- publicKey = chatRoomId,
- kind = CuratedEntryEvent.CANONICAL_KIND,
- tags = arrayOf(arrayOf("d", identifier), arrayOf("title", title)),
- content = "",
- signature = "f".repeat(128),
- createdAt = Instant.fromEpochSeconds(createdAt)
- ),
- list = list,
- entry = CuratedEntry(
- kind = CuratedEntryEvent.CANONICAL_KIND,
- id = createdAt.toString().padStart(64, 'd'),
- pubKey = chatRoomId,
- createdAt = createdAt,
- identifier = identifier,
- title = title,
- values = mapOf(
- "identifier" to listOf(identifier),
- "title" to listOf(title),
- "year" to listOf("2014"),
- ),
- source = suggestedBy?.let {
- CuratedSuggestionRef(id = "a".repeat(64), coordinate = "31888:$it:$identifier", pubKey = it)
- }
- )
- )
- }
-}
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupSignedWorkRowsJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupSignedWorkRowsJvmTest.kt
deleted file mode 100644
index 76368e53..00000000
--- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupSignedWorkRowsJvmTest.kt
+++ /dev/null
@@ -1,282 +0,0 @@
-package press.mantra.compose.ui.composable
-
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.test.ComposeUiTest
-import androidx.compose.ui.test.ExperimentalTestApi
-import androidx.compose.ui.test.assertCountEquals
-import androidx.compose.ui.test.assertIsDisplayed
-import androidx.compose.ui.test.getBoundsInRoot
-import androidx.compose.ui.test.onAllNodesWithText
-import androidx.compose.ui.test.onNodeWithText
-import androidx.compose.ui.test.performClick
-import androidx.compose.ui.test.runDesktopComposeUiTest
-import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
-import press.mantra.compose.nostr.GroupCuratedEntry
-import press.mantra.compose.nostr.GroupCuratedSchema
-import press.mantra.compose.nostr.GroupNostrProfile
-import press.mantra.compose.nostr.GroupPost
-import press.mantra.compose.nostr.GroupRelayList
-import press.mantra.compose.repository.ChatRepository
-import press.mantra.compose.repository.MantraRepository
-import press.mantra.compose.repository.NostrRepository
-import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.activeUserPublicKey
-import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.chatRoomId
-import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.entry
-import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.post
-import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.profile
-import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.schema
-import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.signedRelayLists
-import press.mantra.compose.ui.composable.navigation.routes.GroupCuratedEntriesRoute
-import press.mantra.compose.ui.composable.navigation.routes.GroupCuratedSchemasRoute
-import press.mantra.compose.ui.composable.navigation.routes.GroupNostrProfileRoute
-import press.mantra.compose.ui.composable.navigation.routes.GroupPostsRoute
-import press.mantra.compose.ui.composable.navigation.routes.GroupRelaysRoute
-import press.mantra.compose.ui.composable.navigation.routes.Route
-import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost
-import press.mantra.compose.ui.theme.MantraTheme
-import press.mantra.compose.ui.view.state.ChatRoomDetailUIState
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertTrue
-
-/**
- * The five rows on the group's screen that stand for its signed work, and what
- * each one opens.
- *
- * The rows' contents need little testing -- a name and a count in a `ListItem`
- * -- but three things about them do, and none is settled by reading the screen
- * top to bottom.
- *
- * **Their place in the order.** They go under what the room *is* -- the key it
- * signs as and whose child it is -- and above what the room has *made*, because
- * the group's account of itself is part of its identity rather than part of its
- * work; and among themselves they read as a chain, from who the group says it is
- * to what it has decided. That ordering is an intention a later `item {}` in
- * the wrong place would silently undo, and the screen is long enough that
- * nobody would notice.
- *
- * **What they open.** A row is a promise about the screen behind it, and five
- * rows that look alike are five chances to wire one to the wrong route.
- *
- * **Their absence in a NIP-17 room.** A NIP-17 room's id is a conversation, not
- * a key it can sign as, so it has no nostr identity and never will. A row there
- * would promise something that is not coming, which is worse than saying
- * nothing.
- */
-@OptIn(ExperimentalTestApi::class)
-class GroupSignedWorkRowsJvmTest {
-
- @Test
- fun `the rows sit under the signing key and above the group's work, in the order the block reads`() =
- render(state(groupNostrProfile = profile)) {
- val signingKey = onNodeWithText("Signing key").getBoundsInRoot().top
- val nostrProfile = onNodeWithText("Nostr profile").getBoundsInRoot().top
- val relays = onNodeWithText("Relays").getBoundsInRoot().top
- val posts = onNodeWithText("Posts").getBoundsInRoot().top
- val schemas = onNodeWithText("Curated schemas").getBoundsInRoot().top
- val entries = onNodeWithText("Curated entries").getBoundsInRoot().top
- val subgroups = onNodeWithText("Subgroups").getBoundsInRoot().top
-
- assertTrue(signingKey < nostrProfile, "the nostr profile row climbed above the signing key row")
- assertTrue(nostrProfile < relays, "the relays row climbed above the profile")
- assertTrue(relays < posts, "the posts row climbed above the relays")
- assertTrue(posts < schemas, "the curated schemas row climbed above the posts")
- assertTrue(schemas < entries, "the curated entries row climbed above the schemas")
- assertTrue(entries < subgroups, "the identity block fell below the group's work")
- }
-
- @Test
- fun `each row says what its screen holds`() = render(
- state(
- groupNostrProfile = profile,
- relayLists = signedRelayLists(),
- posts = listOf(post("The second thing", 1_700_000_900), post("The first thing", 1_700_000_000)),
- schemas = listOf(schema("films", "Films worth translating", 1_700_000_000))
- )
- ) {
- // The group's own name and address, not the room's subject: the row is
- // about what the rest of nostr sees.
- onNodeWithText("Translation collective · group@example.com").assertIsDisplayed()
- // Counted against the four sets, the withdrawn one included: an agreed
- // empty list is a decision, and the row counts decisions.
- onNodeWithText("2 of 4 lists agreed").assertIsDisplayed()
- onNodeWithText("2 posts").assertIsDisplayed()
- onNodeWithText("1 schema").assertIsDisplayed()
- onNodeWithText("No entries yet").assertIsDisplayed()
- }
-
- @Test
- fun `a group that has signed nothing has rows that say so`() = render(
- state(groupNostrProfile = null)
- ) {
- onNodeWithText("Not described yet").assertIsDisplayed()
- onNodeWithText("0 of 4 lists agreed").assertIsDisplayed()
- onNodeWithText("No posts yet").assertIsDisplayed()
- onNodeWithText("No schemas yet").assertIsDisplayed()
- onNodeWithText("No entries yet").assertIsDisplayed()
- }
-
- @Test
- fun `a profile with no name is not named after the key`() = render(
- state(
- groupNostrProfile = GroupNostrProfile(
- signedEvent = profile.signedEvent,
- metadata = UserMetadata()
- )
- )
- ) {
- // The signing key row already shows the key, and showing it twice would
- // say the group has a name when it has not.
- onNodeWithText("No name on this profile").assertIsDisplayed()
- }
-
- @Test
- fun `each row opens the screen for its section`() {
- val expected = mapOf(
- "Nostr profile" to GroupNostrProfileRoute(activeUserPublicKey, chatRoomId, relayHint = null),
- "Relays" to GroupRelaysRoute(activeUserPublicKey, chatRoomId, relayHint = null),
- "Posts" to GroupPostsRoute(activeUserPublicKey, chatRoomId, relayHint = null),
- "Curated schemas" to GroupCuratedSchemasRoute(activeUserPublicKey, chatRoomId, relayHint = null),
- "Curated entries" to GroupCuratedEntriesRoute(activeUserPublicKey, chatRoomId, relayHint = null),
- )
-
- expected.forEach { (row, route) ->
- var opened: Route? = null
-
- render(state(groupNostrProfile = profile), onNavigateToRoute = { opened = it }) {
- onNodeWithText(row).performClick()
- }
-
- assertEquals(route, opened, "the $row row opened the wrong screen")
- }
- }
-
- @Test
- fun `the contents stay off the group's screen`() = render(
- state(
- groupNostrProfile = profile,
- relayLists = signedRelayLists(),
- posts = listOf(post("Something the group said", 1_700_000_000)),
- schemas = listOf(schema("films", "Films worth translating", 1_700_000_000)),
- entries = listOf(entry("The Rise and Rise of Bitcoin", 1_700_000_900))
- )
- ) {
- // The rows summarise; the cards, their broadcasts and the gated ways in
- // are on the screens behind them. A card that stayed here would be the
- // old section under a new name.
- onNodeWithText("A group translating hard books into Sesotho.").assertDoesNotExist()
- onNodeWithText("relay.one.example").assertDoesNotExist()
- onNodeWithText("Something the group said").assertDoesNotExist()
- onNodeWithText("The Rise and Rise of Bitcoin").assertDoesNotExist()
- onAllNodesWithText("Broadcast").assertCountEquals(0)
- onNodeWithText("Edit Nostr profile").assertDoesNotExist()
- onNodeWithText("Edit relays").assertDoesNotExist()
- onNodeWithText("Add post").assertDoesNotExist()
- onNodeWithText("Add schema").assertDoesNotExist()
- onNodeWithText("View suggestions").assertDoesNotExist()
- }
-
- @Test
- fun `a member holding a share of the key may propose any event the rows can open`() = render(
- state(groupNostrProfile = profile)
- ) {
- // Last in the identity block: after the rows and before the divider,
- // because it is about the whole block rather than any one row of it.
- val entries = onNodeWithText("Curated entries").getBoundsInRoot().top
- val propose = onNodeWithText("Propose event").getBoundsInRoot().top
- val subgroups = onNodeWithText("Subgroups").getBoundsInRoot().top
-
- assertTrue(entries < propose, "the paste entry climbed above the rows")
- assertTrue(propose < subgroups, "the paste entry fell out of the identity block")
- }
-
- @Test
- fun `a member holding no share of the key is offered no paste`() = render(
- state(groupNostrProfile = profile, canEditNostrProfile = false)
- ) {
- // The same gate as every way in behind the rows: proposing is a signature
- // by the group, and `proposeSigning` throws for a device holding no share.
- // The rows themselves stay, because reading takes no share.
- onNodeWithText("Propose event").assertDoesNotExist()
- onNodeWithText("Nostr profile").assertIsDisplayed()
- onNodeWithText("Curated entries").assertIsDisplayed()
- }
-
- @Test
- fun `a NIP-17 room has no nostr identity and so no rows`() = render(
- state(groupNostrProfile = null, mlsGroupState = null, canEditNostrProfile = false)
- ) {
- onNodeWithText("Nostr profile").assertDoesNotExist()
- onNodeWithText("Not described yet").assertDoesNotExist()
-
- // The other four go with it: all five describe a nostr identity this
- // room does not have, and so does the paste.
- onNodeWithText("Relays").assertDoesNotExist()
- onNodeWithText("Posts").assertDoesNotExist()
- onNodeWithText("Curated schemas").assertDoesNotExist()
- onNodeWithText("Curated entries").assertDoesNotExist()
- onNodeWithText("Propose event").assertDoesNotExist()
-
- // The rest of the screen is untouched, so the rows' absence is an
- // absence rather than a screen that failed to draw.
- onNodeWithText("Subgroups").assertIsDisplayed()
- }
-
- private fun state(
- groupNostrProfile: GroupNostrProfile?,
- relayLists: List = GroupRelayList.allAmong(emptyList(), chatRoomId),
- posts: List = emptyList(),
- schemas: List = emptyList(),
- entries: List = emptyList(),
- canEditNostrProfile: Boolean = true,
- mlsGroupState: String? = "state"
- ) = ChatRoomDetailUIState.Loaded(
- localChatRoom = GroupSignedWorkFixtures.localChatRoom(mlsGroupState),
- groupKeyState = GroupSignedWorkFixtures.groupKeyState,
- groupNostrProfile = groupNostrProfile,
- groupRelayLists = relayLists,
- groupPosts = posts,
- groupCuratedSchemas = schemas,
- groupCuratedEntries = entries,
- canEditNostrProfile = canEditNostrProfile
- )
-
- /**
- * The screen at a phone's width and a window tall enough to compose the whole
- * of it, since a `LazyColumn` does not lay out what it would not show.
- *
- * The state is passed in rather than loaded, so the NO_OP repositories are
- * never asked for anything: `initiateChatRoomDetail` only runs for a screen
- * that arrives in [ChatRoomDetailUIState.Loading].
- */
- private fun render(
- uiState: ChatRoomDetailUIState,
- onNavigateToRoute: (Route) -> Unit = {},
- assertions: ComposeUiTest.() -> Unit
- ) = runDesktopComposeUiTest(width = 400, height = 4000) {
- setContent {
- MantraTheme {
- ProvideSnackbarHost {
- Box(modifier = Modifier.fillMaxSize()) {
- ChatRoomDetailScreen(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = null,
- initialChatRoomDetailUIState = uiState,
- nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
- chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
- mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY,
- onNavigateToRoute = onNavigateToRoute,
- onPopBackToRoute = {},
- onNavigateBack = {},
- )
- }
- }
- }
- }
-
- assertions()
- }
-}
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProposeGroupEventScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProposeGroupEventScreenJvmTest.kt
deleted file mode 100644
index 19d7afd4..00000000
--- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProposeGroupEventScreenJvmTest.kt
+++ /dev/null
@@ -1,117 +0,0 @@
-package press.mantra.compose.ui.composable
-
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.test.ComposeUiTest
-import androidx.compose.ui.test.ExperimentalTestApi
-import androidx.compose.ui.test.assertIsDisplayed
-import androidx.compose.ui.test.assertIsNotEnabled
-import androidx.compose.ui.test.onNodeWithContentDescription
-import androidx.compose.ui.test.onNodeWithText
-import androidx.compose.ui.test.performTextInput
-import androidx.compose.ui.test.runDesktopComposeUiTest
-import press.mantra.compose.database.model.ChatRoom
-import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.repository.ChatRepository
-import press.mantra.compose.repository.FrostSigningRepository
-import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost
-import press.mantra.compose.ui.theme.MantraTheme
-import press.mantra.compose.ui.view.state.ProposeGroupEventUIState
-import kotlin.test.Test
-
-/**
- * What the paste screen says back as an event is typed into it.
- *
- * The screen's one job beyond a text field is to tell a member, before a quorum
- * is asked, what the group would be signing or why it will not be asked. Both
- * are read live off the paste, so both are tested by typing: a schema reads back
- * as the signing screen would describe it, junk is named as junk, and a kind
- * with no section to land in is refused with the kinds that have one.
- */
-@OptIn(ExperimentalTestApi::class)
-class ProposeGroupEventScreenJvmTest {
-
- private val chatRoomId = "d4c3b2a1".repeat(8)
-
- private val schema = """{"kind":31889,"created_at":1735689600,"tags":[["d","bitcoin.mov"],""" +
- """["title","bitcoin.mov suggestion"],["name","bitcoin.mov"],""" +
- """["description","Fields for a bitcoin.mov entry."],["visibility","public"],""" +
- """["field","title","text","required","","Title","{}"]],"content":"Fields for a bitcoin.mov entry."}"""
-
- @Test
- fun `a pasted schema is read back as the signing screen would say it`() = render(state()) {
- onNodeWithText("Unsigned event").performTextInput(schema)
-
- onNodeWithText("What the group would sign").assertIsDisplayed()
- onNodeWithText("Kind 31889 · Curated schema").assertIsDisplayed()
- onNodeWithText("bitcoin.mov · public · 1 field").assertIsDisplayed()
- }
-
- @Test
- fun `junk is named as junk`() =
- render(state()) {
- onNodeWithText("Unsigned event").performTextInput("this is not an event")
- onNodeWithText("That isn't JSON.").assertIsDisplayed()
- onNodeWithText("What the group would sign").assertDoesNotExist()
- }
-
- @Test
- fun `a kind the screen cannot file is refused with the kinds it can`() = render(state()) {
- onNodeWithText("Unsigned event").performTextInput("""{"kind": 30023, "content": "An article"}""")
-
- onNodeWithText("Kind 30023 has nowhere to go on the group's screen.", substring = true)
- .assertIsDisplayed()
- onNodeWithText("0, 1, 10002, 10006, 10007, 10050, 31889", substring = true).assertIsDisplayed()
- }
-
- @Test
- fun `a member holding no share of the key is told so and the button is inert`() = render(
- state(canSign = false)
- ) {
- onNodeWithText("This group has no shared key, so it cannot sign an event.", substring = true)
- .assertIsDisplayed()
- onNodeWithContentDescription("Propose event").assertIsNotEnabled()
- }
-
- private fun state(canSign: Boolean = true) = ProposeGroupEventUIState.Loaded(
- localChatRoom = LocalChatRoom(
- chatRoom = ChatRoom(
- id = chatRoomId,
- userPublicKey = "a".repeat(64),
- subject = "Translation room",
- description = "A group translating hard books.",
- initialGiftWrapPayloadId = "sdfaer",
- mlsGroupState = "state"
- )
- ),
- canSign = canSign,
- )
-
- private fun render(
- uiState: ProposeGroupEventUIState,
- assertions: ComposeUiTest.() -> Unit
- ) = runDesktopComposeUiTest(width = 400, height = 1200) {
- setContent {
- MantraTheme {
- ProvideSnackbarHost {
- Box(modifier = Modifier.fillMaxSize()) {
- ProposeGroupEventScreen(
- activeUserPublicKey = "a".repeat(64),
- chatRoomId = chatRoomId,
- relayHint = null,
- initialProposeGroupEventUIState = uiState,
- chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
- frostSigningRepository =
- FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
- onNavigateToRouteAndPopUpInclusive = {},
- onNavigateBack = {},
- )
- }
- }
- }
- }
-
- assertions()
- }
-}
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/AcceptCuratedSuggestionViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/AcceptCuratedSuggestionViewModelJvmTest.kt
deleted file mode 100644
index 97f4d2cc..00000000
--- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/AcceptCuratedSuggestionViewModelJvmTest.kt
+++ /dev/null
@@ -1,412 +0,0 @@
-package press.mantra.compose.ui.view.model
-
-import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
-import com.vitorpamplona.quartz.nip01Core.core.HexKey
-import com.vitorpamplona.quartz.nip01Core.core.Kind
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.flowOf
-import kotlinx.coroutines.runBlocking
-import kotlinx.coroutines.test.UnconfinedTestDispatcher
-import kotlinx.coroutines.test.resetMain
-import kotlinx.coroutines.test.setMain
-import kotlinx.coroutines.withTimeout
-import press.mantra.compose.database.model.ChatRoom
-import press.mantra.compose.database.model.FrostSigningSession
-import press.mantra.compose.database.model.GroupSignedEvent
-import press.mantra.compose.database.model.NostrEvent
-import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.database.model.intermdiate.LocalNostrEvent
-import press.mantra.compose.database.model.types.SynchronizationFilter
-import press.mantra.compose.nostr.CuratedSuggestion
-import press.mantra.compose.nostr.GroupCuratedSchema
-import press.mantra.compose.nostr.curated.CuratedEntryEvent
-import press.mantra.compose.nostr.curated.CuratedEntryProblem
-import press.mantra.compose.nostr.curated.CuratedField
-import press.mantra.compose.nostr.curated.CuratedFieldConfig
-import press.mantra.compose.nostr.curated.CuratedFieldType
-import press.mantra.compose.nostr.curated.CuratedSchema
-import press.mantra.compose.nostr.curated.CuratedSchemaEvent
-import press.mantra.compose.nostr.curated.CuratedVisibility
-import press.mantra.compose.repository.ChatRepository
-import press.mantra.compose.repository.FrostSigningRepository
-import press.mantra.compose.repository.NostrRepository
-import press.mantra.compose.ui.view.state.AcceptCuratedSuggestionUIState
-import kotlin.test.AfterTest
-import kotlin.test.BeforeTest
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertFalse
-import kotlin.test.assertNotNull
-import kotlin.test.assertNull
-import kotlin.test.assertTrue
-import kotlin.time.Instant
-
-/**
- * The decisions the accept form makes that nothing else can check.
- *
- * **What is opened and what is kept.** The form asks for what the schema's
- * form asks for, and keeps the `d` and the derived fields as they were
- * suggested; getting that wrong in one direction lets a member invent a
- * hashtag the app was supposed to derive, and in the other loses the
- * coordinate that makes a re-curation a revision.
- *
- * **What the button refuses.** An entry the queue would not count is refused
- * with the field named, before a quorum is asked for it -- and the entry it
- * does propose is the one the queue screen will read back, source and all.
- *
- * **What the loader finds.** The suggestion is looked up by the queue's own
- * reading, so what is accepted is what was shown.
- */
-@OptIn(ExperimentalCoroutinesApi::class)
-class AcceptCuratedSuggestionViewModelJvmTest {
- private val chatRoomId = "d4c3b2a1".repeat(8)
- private val ada = "a".repeat(64)
- private val bob = "b".repeat(64)
-
- private val schema = GroupCuratedSchema(
- signedEvent = GroupSignedEvent(
- id = "c".repeat(64),
- chatRoomId = chatRoomId,
- publicKey = chatRoomId,
- kind = CuratedSchemaEvent.KIND,
- tags = emptyArray(),
- content = "",
- signature = "f".repeat(128),
- createdAt = Instant.fromEpochSeconds(1_700_000_000),
- ),
- schema = CuratedSchema(
- identifier = "films",
- title = "Film suggestion",
- name = "Films worth translating",
- description = "Films the group would subtitle.",
- visibility = CuratedVisibility.Public,
- fields = listOf(
- CuratedField.IDENTIFIER,
- CuratedField.TITLE,
- CuratedField("year", CuratedFieldType.Year, isRequired = false, label = "Year", config = CuratedFieldConfig(min = 1900, max = 2100)),
- CuratedField("type", CuratedFieldType.Enum, isRequired = true, label = "Type", config = CuratedFieldConfig(options = listOf("movie", "documentary"))),
- CuratedField("watchUrl", CuratedFieldType.Url, isRequired = false, label = "Watch URL", config = CuratedFieldConfig(tag = "r", marker = "watch", repeat = true)),
- CuratedField("imdbUrl", CuratedFieldType.Url, isRequired = false, label = "IMDb URL", config = CuratedFieldConfig(tag = "r", marker = "imdb")),
- CuratedField("hashtags", CuratedFieldType.Token, isRequired = false, label = "Hashtags", config = CuratedFieldConfig(tag = "t", repeat = true, derived = true)),
- CuratedField("review", CuratedFieldType.LongText, isRequired = false, label = "Review", config = CuratedFieldConfig(tag = CuratedFieldConfig.CONTENT_TAG)),
- ),
- requireAny = listOf(listOf("watchUrl", "imdbUrl")),
- relays = listOf("wss://relay.one.example"),
- ).normalized(),
- )
-
- private val localChatRoom = LocalChatRoom(
- chatRoom = ChatRoom(
- id = chatRoomId,
- userPublicKey = ada,
- subject = "Translation room",
- description = null,
- initialGiftWrapPayloadId = "sdfaer",
- mlsGroupState = "state"
- )
- )
-
- /** *Magic Money* as Bob suggested it: two watch links, and hashtags his client derived. */
- private val suggestedEvent = NostrEvent(
- id = "1".repeat(64),
- pubKey = bob,
- kind = CuratedSchemaEvent.SUGGESTION_KIND,
- tags = arrayOf(
- arrayOf("d", "magic-money"),
- arrayOf("title", "Magic Money"),
- arrayOf("year", "2018"),
- arrayOf("type", "documentary"),
- arrayOf("r", "https://example.com/watch", "watch"),
- arrayOf("r", "https://mirror.example/watch", "watch"),
- arrayOf("t", "bitcoin"),
- arrayOf("t", "documentary"),
- arrayOf("a", schema.coordinate(), "", "root"),
- ),
- content = "Follows the early community.",
- sig = "b".repeat(128),
- createdAt = Instant.fromEpochSeconds(1_700_000_000),
- )
-
- private val suggestion: CuratedSuggestion = CuratedSuggestion.queueOf(
- schema,
- listOf(LocalNostrEvent(nostrEvent = suggestedEvent, profile = null))
- ).single()
-
- private fun loaded(canSign: Boolean = true) = AcceptCuratedSuggestionUIState.Loaded(
- localChatRoom = localChatRoom,
- schema = schema,
- suggestion = suggestion,
- canSign = canSign,
- )
-
- /** What was asked of the signing repository, and the session it answers with. */
- private class RecordingSigning(private val session: FrostSigningSession?) :
- FrostSigningRepository by FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY {
- var kind: Kind? = null
- var tags: Array>? = null
- var content: String? = null
-
- override suspend fun canSign(chatRoomId: String): Boolean = true
-
- override suspend fun proposeSigning(
- localChatRoom: LocalChatRoom,
- userPublicKey: HexKey,
- kind: Kind,
- tags: Array>,
- content: String
- ): FrostSigningSession? {
- this.kind = kind
- this.tags = tags
- this.content = content
- return session
- }
- }
-
- private fun session() = FrostSigningSession(
- id = "session-1",
- chatRoomId = chatRoomId,
- coordinatorPublicKey = ada,
- userPublicKey = ada,
- dkgSessionId = "dkg-1",
- threshold = 2,
- participantCount = 3,
- signerId = 1,
- )
-
- private fun viewModel(
- signing: FrostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
- nostrRepository: NostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
- chatRepository: ChatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
- suggestionId: String = suggestion.id,
- ) = AcceptCuratedSuggestionViewModel(
- chatRoomId = chatRoomId,
- activeUserPublicKey = ada,
- relayHint = null,
- identifier = "films",
- suggestionId = suggestionId,
- initialAcceptCuratedSuggestionUIState = AcceptCuratedSuggestionUIState.Loading,
- nostrRepository = nostrRepository,
- chatRepository = chatRepository,
- frostSigningRepository = signing,
- )
-
- @BeforeTest
- fun setUp() {
- Dispatchers.setMain(UnconfinedTestDispatcher())
- }
-
- @AfterTest
- fun tearDown() {
- Dispatchers.resetMain()
- }
-
- @Test
- fun `the form opens what the schema asks for, one input per value, and keeps the rest`() {
- val state = loaded()
- val viewModel = viewModel()
- viewModel.seedWorkingCopy(state)
-
- // Not the identifier -- it writes to `d` -- and not the derived hashtags.
- assertEquals(
- listOf("title", "year", "type", "watchUrl", "imdbUrl", "review"),
- AcceptCuratedSuggestionViewModel.editableFields(state).map { it.name }
- )
- assertEquals(
- listOf("identifier", "hashtags"),
- AcceptCuratedSuggestionViewModel.keptFields(state).map { it.name }
- )
-
- assertEquals(listOf("Magic Money"), viewModel.inputs["title"]?.map { it.text.toString() })
- assertEquals(listOf("2018"), viewModel.inputs["year"]?.map { it.text.toString() })
- // A field that repeats gets an input per value the suggestion carried.
- assertEquals(
- listOf("https://example.com/watch", "https://mirror.example/watch"),
- viewModel.inputs["watchUrl"]?.map { it.text.toString() }
- )
- // A field the suggestion has nothing for gets one blank to type into.
- assertEquals(listOf(""), viewModel.inputs["imdbUrl"]?.map { it.text.toString() })
- assertNull(viewModel.inputs["identifier"], "the identifier was opened for editing")
- assertNull(viewModel.inputs["hashtags"], "a derived field was opened for editing")
- }
-
- @Test
- fun `seeding again leaves an edit alone`() {
- val state = loaded()
- val viewModel = viewModel()
- viewModel.seedWorkingCopy(state)
- viewModel.inputs["year"]!![0].setTextAndPlaceCursorAtEnd("2019")
-
- viewModel.seedWorkingCopy(state)
-
- assertEquals("2019", viewModel.inputs["year"]!![0].text.toString())
- }
-
- @Test
- fun `the draft is what was typed plus what was kept, and the d is the suggestion's`() {
- val state = loaded()
- val viewModel = viewModel()
- viewModel.seedWorkingCopy(state)
-
- viewModel.inputs["year"]!![0].setTextAndPlaceCursorAtEnd("2019")
- viewModel.addValue("watchUrl")
- viewModel.inputs["watchUrl"]!![2].setTextAndPlaceCursorAtEnd("https://third.example/watch")
- viewModel.removeValue("watchUrl", 0)
-
- val draft = viewModel.draft(state)
-
- assertEquals(listOf("magic-money"), draft["identifier"])
- assertEquals(listOf("2019"), draft["year"])
- assertEquals(listOf("https://mirror.example/watch", "https://third.example/watch"), draft["watchUrl"])
- assertEquals(listOf("bitcoin", "documentary"), draft["hashtags"])
- // A blank input comes through; the builder is what drops it.
- assertEquals(listOf(""), draft["imdbUrl"])
- }
-
- @Test
- fun `removing the last input leaves a blank one rather than nothing`() {
- val state = loaded()
- val viewModel = viewModel()
- viewModel.seedWorkingCopy(state)
-
- viewModel.removeValue("year", 0)
-
- assertEquals(listOf(""), viewModel.inputs["year"]?.map { it.text.toString() })
- }
-
- @Test
- fun `an entry the queue would refuse is refused with the field named, and nothing is proposed`() {
- val state = loaded()
- val signing = RecordingSigning(session())
- val viewModel = viewModel(signing)
- viewModel.seedWorkingCopy(state)
-
- viewModel.inputs["year"]!![0].setTextAndPlaceCursorAtEnd("1850")
- viewModel.inputs["type"]!![0].setTextAndPlaceCursorAtEnd("musical")
-
- var invalid = false
- viewModel.proposeEntry(state, onSuccess = { error("proposed") }, onInvalid = { invalid = true }, onFailure = { error("failed") })
-
- assertTrue(invalid)
- assertEquals(listOf(CuratedEntryProblem.Reason.BelowMinimum), viewModel.problemsFor("year").map { it.reason })
- assertEquals(listOf(CuratedEntryProblem.Reason.NotAnOption), viewModel.problemsFor("type").map { it.reason })
- assertEquals(emptyList(), viewModel.problemsAboutTheEntry(state))
- assertNull(signing.kind, "a refused entry was proposed anyway")
- assertFalse(viewModel.isActionPending.value)
- }
-
- @Test
- fun `a require-any rule the edit broke is filed under its first field`() {
- val state = loaded()
- val viewModel = viewModel()
- viewModel.seedWorkingCopy(state)
-
- viewModel.removeValue("watchUrl", 0)
- viewModel.removeValue("watchUrl", 0)
-
- viewModel.proposeEntry(state, onSuccess = {}, onInvalid = {}, onFailure = {})
-
- assertEquals(listOf(CuratedEntryProblem.Reason.RequireAnyUnmet), viewModel.problemsFor("watchUrl").map { it.reason })
- }
-
- @Test
- fun `the entry proposed is the canonical one the queue will read back`() {
- val state = loaded()
- val signing = RecordingSigning(session())
- val viewModel = viewModel(signing)
- viewModel.seedWorkingCopy(state)
- viewModel.inputs["year"]!![0].setTextAndPlaceCursorAtEnd("2019")
-
- var sessionId: String? = null
- viewModel.proposeEntry(state, onSuccess = { sessionId = it }, onInvalid = { error("invalid") }, onFailure = { error("failed") })
-
- runBlocking {
- withTimeout(5_000) {
- while (sessionId == null) kotlinx.coroutines.delay(10)
- }
- }
-
- assertEquals("session-1", sessionId)
- assertEquals(CuratedEntryEvent.CANONICAL_KIND, signing.kind)
- assertEquals("Follows the early community.", signing.content)
-
- val tags = assertNotNull(signing.tags).map { it.toList() }
- assertTrue(listOf("d", "magic-money") in tags, "the entry lost the suggestion's d")
- assertTrue(listOf("year", "2019") in tags, "the edit was not signed")
- assertTrue(listOf("t", "bitcoin") in tags && listOf("t", "documentary") in tags, "the derived hashtags were dropped")
- assertTrue(listOf("a", schema.coordinate(), "wss://relay.one.example", "root") in tags, "the entry does not reply to the list")
- assertTrue(listOf("a", "31888:$bob:magic-money", "", "mention") in tags, "the entry does not point at the suggestion")
- assertTrue(listOf("e", suggestion.id, "", "mention") in tags, "the entry does not pin the version read")
- assertTrue(listOf("p", bob) in tags, "the suggester is not credited")
-
- // And it is what the queue counts as the group's entry for that suggestion.
- val signed = NostrEvent(
- id = "2".repeat(64),
- pubKey = chatRoomId,
- kind = CuratedEntryEvent.CANONICAL_KIND,
- tags = signing.tags!!,
- content = signing.content!!,
- sig = "b".repeat(128),
- createdAt = Instant.fromEpochSeconds(1_700_000_900),
- )
- val queue = CuratedSuggestion.queueOf(
- schema,
- listOf(LocalNostrEvent(suggestedEvent, null), LocalNostrEvent(signed, null))
- )
- assertTrue(queue.single().isCurated, "the queue does not count the proposed entry as curating the suggestion")
- }
-
- @Test
- fun `a session that could not be opened is a failure, and the form stays`() {
- val state = loaded()
- val viewModel = viewModel(RecordingSigning(session = null))
- viewModel.seedWorkingCopy(state)
-
- var failed = false
- viewModel.proposeEntry(state, onSuccess = { error("proposed") }, onInvalid = { error("invalid") }, onFailure = { failed = true })
-
- runBlocking {
- withTimeout(5_000) {
- while (!failed) kotlinx.coroutines.delay(10)
- }
- }
-
- assertEquals(emptyList(), viewModel.problems)
- assertEquals("Magic Money", viewModel.inputs["title"]!![0].text.toString())
- }
-
- @Test
- fun `the loader finds the suggestion by the queue's reading, and says when it cannot`() {
- val nostrRepository = object : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY {
- override suspend fun observeNostrEventsMatching(synchronizationFilter: SynchronizationFilter): Flow> =
- flowOf(listOf(LocalNostrEvent(nostrEvent = suggestedEvent, profile = null)))
- }
- val chatRepository = object : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY {
- override suspend fun getChatRoomByIdentifier(id: HexKey): LocalChatRoom? = localChatRoom
- override suspend fun groupCuratedSchemas(chatRoomId: String): List = listOf(schema)
- }
-
- val found = viewModel(RecordingSigning(null), nostrRepository, chatRepository)
- found.initiate()
- val loaded = await { found.acceptCuratedSuggestionUIState as? AcceptCuratedSuggestionUIState.Loaded }
- assertEquals("Magic Money", loaded.suggestion.entry.title)
- assertTrue(loaded.canSign)
-
- val missing = viewModel(RecordingSigning(null), nostrRepository, chatRepository, suggestionId = "9".repeat(64))
- missing.initiate()
- val error = await { missing.acceptCuratedSuggestionUIState as? AcceptCuratedSuggestionUIState.Error }
- assertEquals("Couldn't find that suggestion", error.message)
- }
-
- private fun await(read: () -> T?): T = runBlocking {
- withTimeout(5_000) {
- var value = read()
- while (value == null) {
- kotlinx.coroutines.delay(10)
- value = read()
- }
- value
- }
- }
-}
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModelJvmTest.kt
index 9ef230c7..6c456094 100644
--- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModelJvmTest.kt
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModelJvmTest.kt
@@ -1,10 +1,8 @@
package press.mantra.compose.ui.view.model
-import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
-import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
@@ -22,15 +20,8 @@ import press.mantra.compose.network.dto.RelayDTO
import press.mantra.compose.network.relays.EventPublishTransport
import press.mantra.compose.network.relays.NostrPublishResult
import press.mantra.compose.network.sockets.NostrIncomingMessage
-import press.mantra.compose.nostr.GroupRelay
-import press.mantra.compose.nostr.GroupRelayList
-import press.mantra.compose.nostr.GroupRelaySet
import press.mantra.compose.nostr.Relays
-import press.mantra.compose.nostr.GroupCuratedSchema
-import press.mantra.compose.nostr.curated.CuratedEntryEvent
-import press.mantra.compose.nostr.curated.CuratedSchema
-import press.mantra.compose.nostr.curated.CuratedSchemaEvent
-import press.mantra.compose.nostr.curated.CuratedVisibility
+import press.mantra.compose.nostr.frost.GroupKeyStateEvent
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.ui.view.model.BroadcastGroupSignedEventViewModel.RelayOutcome
import press.mantra.compose.ui.view.state.BroadcastGroupSignedEventUIState
@@ -48,9 +39,8 @@ import kotlin.time.Instant
*
* **Where an event goes before anyone says otherwise.** The seeded list is what a
* member who reads nothing and presses the button sends to, so it has to be the
- * group's own answer where the group has given one, has to leave out what the
- * group has blocked, and has to widen for the two kinds whose readers would not
- * otherwise find them.
+ * app's own publish set -- a group has no relay list of its own to prefer -- and
+ * it has to be seeded once, so a list the member emptied stays empty.
*
* **What each relay's answer becomes.** The pool answers once per relay in three
* shapes -- an OK, a rejection wrapped in one exception type, and any other error
@@ -118,27 +108,6 @@ class BroadcastGroupSignedEventViewModelJvmTest {
createdAt = Instant.fromEpochSeconds(1_700_000_000),
)
- private fun relaySignedEvent(set: GroupRelaySet) = GroupSignedEvent(
- id = "a" + set.kind.toString().padStart(5, '0') + "0".repeat(58),
- chatRoomId = chatRoomId,
- publicKey = chatRoomId,
- kind = set.kind,
- tags = emptyArray(),
- content = "",
- signature = "f".repeat(128),
- createdAt = Instant.fromEpochSeconds(1_700_000_000),
- )
-
- /** Every set unsigned, which is a group that has never said where it lives. */
- private fun nothingAgreed() = GroupRelayList.allAmong(emptyList(), chatRoomId)
-
- private fun agreed(vararg lists: Pair>) =
- nothingAgreed().map { list ->
- lists.firstOrNull { it.first == list.set }
- ?.let { GroupRelayList(list.set, relaySignedEvent(list.set), it.second) }
- ?: list
- }
-
private fun ok(relay: NormalizedRelayUrl) = NostrPublishResult(
result = NostrIncomingMessage.OkMessage(eventId = "b".repeat(64), success = true),
relayUrl = relay.url,
@@ -158,142 +127,32 @@ class BroadcastGroupSignedEventViewModelJvmTest {
// -- where an event goes before anyone says otherwise ------------------------
@Test
- fun `a group that has not said where it lives sends to the app's publish set`() {
+ fun `every kind is seeded with the app's publish set`() {
+ // A group has no relay list of its own, so a post and a key state go to
+ // the same place before the member says otherwise -- the set the app
+ // publishes its own events to, which is what keeps the button from
+ // being a no-op on a group nobody has typed a relay in for.
assertEquals(
Relays.eventPublishRelaySet.toList(),
- BroadcastGroupSignedEventViewModel.defaultRelaysFor(nothingAgreed(), TextNoteEvent.KIND),
+ BroadcastGroupSignedEventViewModel.defaultRelaysFor(TextNoteEvent.KIND),
)
- }
-
- @Test
- fun `a group's own general list wins, and only the relays it writes to`() {
- val relayLists = agreed(
- GroupRelaySet.General to listOf(
- GroupRelay(one),
- // Read-only: where the group looks, not where it puts things.
- GroupRelay(two, read = true, write = false),
- )
- )
-
- // The group's answer and nothing from the app's set beside it: a member
- // who edited the relays meant them, and padding the list would send the
- // group's post somewhere the group decided not to be.
assertEquals(
- listOf(one),
- BroadcastGroupSignedEventViewModel.defaultRelaysFor(relayLists, TextNoteEvent.KIND),
- )
- }
-
- @Test
- fun `a blocked relay is dropped whatever the other lists say`() {
- val relayLists = agreed(
- GroupRelaySet.General to listOf(GroupRelay(one), GroupRelay(two)),
- GroupRelaySet.Blocked to listOf(GroupRelay(two)),
- )
-
- assertEquals(
- listOf(one),
- BroadcastGroupSignedEventViewModel.defaultRelaysFor(relayLists, TextNoteEvent.KIND),
- )
- }
-
- @Test
- fun `a relay list also goes to the indexers, since its readers do not yet know the relays it names`() {
- val relayLists = agreed(GroupRelaySet.General to listOf(GroupRelay(one)))
-
- val seeded = BroadcastGroupSignedEventViewModel.defaultRelaysFor(
- relayLists, AdvertisedRelayListEvent.KIND
- )
-
- assertEquals(one, seeded.first())
- Relays.DefaultIndexerRelayList.forEach { indexer ->
- assertTrue(indexer in seeded, "the relay list would not reach ${indexer.url}")
- }
-
- // And a post does not: nobody looks an indexer up for a kind 1.
- val post = BroadcastGroupSignedEventViewModel.defaultRelaysFor(relayLists, TextNoteEvent.KIND)
- Relays.DefaultIndexerRelayList.forEach { indexer ->
- assertFalse(indexer in post, "a post was sent to the indexer ${indexer.url}")
- }
- }
-
- @Test
- fun `a curated schema also goes to the relays it names for its own replies`() {
- val relayLists = agreed(GroupRelaySet.General to listOf(GroupRelay(one)))
-
- val seeded = BroadcastGroupSignedEventViewModel.defaultRelaysFor(
- relayLists = relayLists,
- kind = CuratedSchemaEvent.KIND,
- tags = arrayOf(
- arrayOf("d", "films"),
- arrayOf("relay", three.url),
- ),
- content = "Films worth translating.",
- )
-
- // The group's relay first, then the list's own -- a client reading
- // suggestions on the list's relay finds the schema they answer.
- assertEquals(listOf(one, three), seeded)
- }
-
- @Test
- fun `an entry into a curated list goes to the list's relays, found by the coordinate it replies to`() {
- val relayLists = agreed(GroupRelaySet.General to listOf(GroupRelay(one)))
-
- val films = GroupCuratedSchema(
- signedEvent = relaySignedEvent(GroupRelaySet.General).copy(kind = CuratedSchemaEvent.KIND),
- schema = CuratedSchema(
- identifier = "films",
- title = "Film suggestion",
- name = "Films worth translating",
- description = "Films the group would subtitle.",
- visibility = CuratedVisibility.Public,
- relays = listOf(three.url),
- ).normalized(),
- )
- val books = films.copy(schema = films.schema.copy(identifier = "books", relays = listOf(two.url)))
-
- val seeded = BroadcastGroupSignedEventViewModel.defaultRelaysFor(
- relayLists = relayLists,
- kind = CuratedEntryEvent.CANONICAL_KIND,
- tags = arrayOf(
- arrayOf("d", "magic-money"),
- arrayOf("title", "Magic Money"),
- arrayOf("a", films.coordinate(), "", "root"),
- ),
- content = "",
- schemas = listOf(books, films),
- )
-
- // The group's relay, then the relays of the list replied to -- and not
- // the other list's: the NIP sends an entry where its own schema says.
- assertEquals(listOf(one, three), seeded)
-
- // An entry whose schema this device does not hold goes where the group
- // publishes, since there is nothing else to go on.
- assertEquals(
- listOf(one),
- BroadcastGroupSignedEventViewModel.defaultRelaysFor(
- relayLists = relayLists,
- kind = CuratedEntryEvent.CANONICAL_KIND,
- tags = arrayOf(arrayOf("a", "31889:${"0".repeat(64)}:elsewhere", "", "root")),
- schemas = listOf(books, films),
- )
+ BroadcastGroupSignedEventViewModel.defaultRelaysFor(TextNoteEvent.KIND),
+ BroadcastGroupSignedEventViewModel.defaultRelaysFor(GroupKeyStateEvent.KIND),
)
}
@Test
fun `seeding happens once, so an emptied list stays empty`() {
val viewModel = viewModel()
- val relayLists = agreed(GroupRelaySet.General to listOf(GroupRelay(one)))
val event = signedEvent()
- viewModel.seedRelays(relayLists, event.kind, event.tags, event.content)
- viewModel.seedRelays(relayLists, event.kind, event.tags, event.content)
- assertEquals(listOf(one), viewModel.relays.toList())
+ viewModel.seedRelays(event.kind)
+ viewModel.seedRelays(event.kind)
+ assertEquals(Relays.eventPublishRelaySet.toList(), viewModel.relays.toList())
- viewModel.removeRelay(one)
- viewModel.seedRelays(relayLists, event.kind, event.tags, event.content)
+ viewModel.relays.toList().forEach { viewModel.removeRelay(it) }
+ viewModel.seedRelays(event.kind)
assertEquals(emptyList(), viewModel.relays.toList())
}
@@ -403,8 +262,8 @@ class BroadcastGroupSignedEventViewModelJvmTest {
@Test
fun `what goes on the wire is the event the group signed, field for field`() {
val event = signedEvent(
- kind = MetadataEvent.KIND,
- tags = arrayOf(arrayOf("alt", "The group's profile")),
+ kind = GroupKeyStateEvent.KIND,
+ tags = arrayOf(arrayOf("alt", "The group's signing key")),
)
val wire = BroadcastGroupSignedEventViewModel.wireEventOf(event).toNostrJsonObject()
@@ -415,6 +274,6 @@ class BroadcastGroupSignedEventViewModelJvmTest {
assertEquals(event.kind.toString(), wire["kind"]!!.jsonPrimitive.content)
assertEquals(event.content, wire["content"]!!.jsonPrimitive.content)
assertEquals(event.signature, wire["sig"]!!.jsonPrimitive.content)
- assertEquals("""[["alt","The group's profile"]]""", wire["tags"].toString())
+ assertEquals("""[["alt","The group's signing key"]]""", wire["tags"].toString())
}
}
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CuratedSuggestionListViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CuratedSuggestionListViewModelJvmTest.kt
deleted file mode 100644
index 73b3463f..00000000
--- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CuratedSuggestionListViewModelJvmTest.kt
+++ /dev/null
@@ -1,335 +0,0 @@
-package press.mantra.compose.ui.view.model
-
-import androidx.lifecycle.viewModelScope
-import com.vitorpamplona.quartz.nip01Core.core.HexKey
-import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
-import kotlinx.coroutines.cancel
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.flowOf
-import kotlinx.coroutines.runBlocking
-import kotlinx.coroutines.withTimeout
-import press.mantra.compose.database.model.ChatRoom
-import press.mantra.compose.database.model.GroupSignedEvent
-import press.mantra.compose.database.model.NostrEvent
-import press.mantra.compose.database.model.SynchronizeNostrEventRequest
-import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.database.model.intermdiate.LocalNostrEvent
-import press.mantra.compose.database.model.types.SynchronizationFilter
-import press.mantra.compose.nostr.CuratedSuggestion
-import press.mantra.compose.nostr.GroupCuratedSchema
-import press.mantra.compose.nostr.GroupRelay
-import press.mantra.compose.nostr.GroupRelayList
-import press.mantra.compose.nostr.GroupRelaySet
-import press.mantra.compose.nostr.Relays
-import press.mantra.compose.nostr.curated.CuratedEntry
-import press.mantra.compose.nostr.curated.CuratedField
-import press.mantra.compose.nostr.curated.CuratedSchema
-import press.mantra.compose.nostr.curated.CuratedSchemaEvent
-import press.mantra.compose.nostr.curated.CuratedVisibility
-import press.mantra.compose.repository.ChatRepository
-import press.mantra.compose.repository.NostrRepository
-import press.mantra.compose.ui.view.state.CuratedSuggestionListUIState
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertFalse
-import kotlin.test.assertIs
-import kotlin.test.assertTrue
-import kotlin.time.Instant
-
-/**
- * The decisions the queue screen's view model makes that nothing else can check.
- *
- * **Where the queue is read from.** The schema's relays, then the group's, then
- * the default -- and getting the order wrong is quiet, because a queue read
- * from the wrong relay is an empty queue with no error attached.
- *
- * **What is asked for.** One request per relay carrying the NIP's two queries,
- * and the local watch over the same rows, so that what comes back is what is
- * shown.
- *
- * **When looking stops.** Rows arriving end it; nothing arriving leaves it to
- * the timer. A screen that stopped looking on an empty first read of the local
- * table would call every list empty before its relays had been asked.
- */
-class CuratedSuggestionListViewModelJvmTest {
- private val chatRoomId = "d4c3b2a1".repeat(8)
- private val ada = "a".repeat(64)
-
- private val one = RelayUrlNormalizer.normalize("wss://relay.one.example")
- private val two = RelayUrlNormalizer.normalize("wss://relay.two.example")
-
- private fun schema(relays: List) = GroupCuratedSchema(
- signedEvent = GroupSignedEvent(
- id = "c".repeat(64),
- chatRoomId = chatRoomId,
- publicKey = chatRoomId,
- kind = CuratedSchemaEvent.KIND,
- tags = emptyArray(),
- content = "",
- signature = "f".repeat(128),
- createdAt = Instant.fromEpochSeconds(1_700_000_000),
- ),
- schema = CuratedSchema(
- identifier = "films",
- title = "Film suggestion",
- name = "Films worth translating",
- description = "Films the group would subtitle.",
- visibility = CuratedVisibility.Public,
- fields = listOf(CuratedField.IDENTIFIER, CuratedField.TITLE),
- relays = relays,
- ).normalized(),
- )
-
- private val localChatRoom = LocalChatRoom(
- chatRoom = ChatRoom(
- id = chatRoomId,
- userPublicKey = ada,
- subject = "Translation room",
- description = null,
- initialGiftWrapPayloadId = "sdfaer",
- mlsGroupState = "state"
- )
- )
-
- private fun general(relays: List, agreed: Boolean = true) = GroupRelayList(
- set = GroupRelaySet.General,
- signedEvent = if (agreed) {
- GroupSignedEvent(
- id = "a".repeat(64),
- chatRoomId = chatRoomId,
- publicKey = chatRoomId,
- kind = GroupRelaySet.General.kind,
- tags = emptyArray(),
- content = "",
- signature = "f".repeat(128),
- createdAt = Instant.fromEpochSeconds(1_700_000_000),
- )
- } else {
- null
- },
- relays = relays,
- )
-
- @Test
- fun `the schema's own relays come first, and two spellings of one are one`() {
- val asked = CuratedSuggestionListViewModel.relaysToAsk(
- schema(listOf("wss://relay.one.example", "wss://relay.two.example/", "wss://relay.one.example/")),
- general = general(listOf(GroupRelay(RelayUrlNormalizer.normalize("wss://relay.three.example")))),
- )
-
- assertEquals(listOf(one.url, two.url), asked)
- assertEquals(2, asked.size, "one relay spelled two ways was asked twice")
- }
-
- @Test
- fun `a schema naming no relays is read from where the group reads`() {
- val three = RelayUrlNormalizer.normalize("wss://relay.three.example")
- val four = RelayUrlNormalizer.normalize("wss://relay.four.example")
-
- // The group's read relays, and not its write-only one: this is a read.
- assertEquals(
- listOf(three.url),
- CuratedSuggestionListViewModel.relaysToAsk(
- schema(emptyList()),
- general = general(listOf(GroupRelay(three), GroupRelay(four, read = false, write = true))),
- )
- )
-
- // Nothing agreed, or agreed empty: the build's own relay rather than nobody.
- assertEquals(
- listOf(Relays.ephemeral.url),
- CuratedSuggestionListViewModel.relaysToAsk(schema(emptyList()), general = null)
- )
- assertEquals(
- listOf(Relays.ephemeral.url),
- CuratedSuggestionListViewModel.relaysToAsk(schema(emptyList()), general = general(emptyList()))
- )
- assertEquals(
- listOf(Relays.ephemeral.url),
- CuratedSuggestionListViewModel.relaysToAsk(
- schema(emptyList()),
- general = general(listOf(GroupRelay(three)), agreed = false)
- )
- )
- }
-
- @Test
- fun `one request per relay, carrying the NIP's two queries for the coordinate`() {
- val schema = schema(listOf(one.url))
- val requests = CuratedSuggestionListViewModel.requestsFor(schema, listOf(one.url, two.url))
-
- assertEquals(listOf(one.url, two.url), requests.map { it.relayURL })
- assertTrue(requests.all { it.purpose == CuratedSuggestionListViewModel.PURPOSE })
-
- val (suggestions, canonical) = requests.first().synchronizationFilters
- val coordinate = "31889:$chatRoomId:films"
-
- assertEquals(listOf(31888), suggestions.kinds?.toList())
- assertEquals(mapOf("a" to listOf(coordinate)), suggestions.tags)
- assertEquals(null, suggestions.authors, "suggestions are not scoped by author: anyone may make one")
-
- assertEquals(listOf(31890), canonical.kinds?.toList())
- assertEquals(listOf(chatRoomId), canonical.authors?.toList(), "canonical entries are the group's alone")
- assertEquals(mapOf("a" to listOf(coordinate)), canonical.tags)
-
- // And the watch covers both, so what is asked for is what is shown.
- val watched = schema.queueReadFilter()
- assertEquals(listOf(31888, 31890), watched.kinds?.toList())
- assertEquals(mapOf("a" to listOf(coordinate)), watched.tags)
- }
-
- @Test
- fun `rows arriving end the looking, and an empty read does not`() {
- val asking = CuratedSuggestionListUIState.Loaded(
- localChatRoom = localChatRoom,
- schema = schema(listOf(one.url)),
- relays = listOf(one.url),
- isAsking = true,
- )
-
- val stillEmpty = CuratedSuggestionListViewModel.withQueue(asking, emptyList())
- assertTrue(stillEmpty.isAsking, "an empty first read of the local table was taken as the relays' answer")
-
- val arrived = CuratedSuggestionListViewModel.withQueue(asking, listOf(row("Magic Money", 1_700_000_000)))
- assertFalse(arrived.isAsking)
- assertEquals(listOf("Magic Money"), arrived.suggestions.map { it.entry.title })
-
- // Once the timer has called it empty, a later arrival is still shown.
- val late = CuratedSuggestionListViewModel.withQueue(stillEmpty.copy(isAsking = false), listOf(row("Late", 1_700_000_900)))
- assertFalse(late.isAsking)
- assertEquals(listOf("Late"), late.suggestions.map { it.entry.title })
- }
-
- @Test
- fun `opening the screen asks the schema's relays and shows what the table holds for them`() {
- val schema = schema(listOf(one.url))
- val queued = mutableListOf()
- val watched = mutableListOf()
-
- val stored = NostrEvent(
- id = "1".repeat(64),
- pubKey = ada,
- kind = CuratedSchemaEvent.SUGGESTION_KIND,
- tags = arrayOf(
- arrayOf("d", "magic-money"),
- arrayOf("title", "Magic Money"),
- arrayOf("a", schema.coordinate(), "", "root"),
- ),
- content = "",
- sig = "b".repeat(128),
- createdAt = Instant.fromEpochSeconds(1_700_000_000),
- )
-
- val nostrRepository = object : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY {
- override suspend fun queueSynchronizeNostrEvent(synchronizeNostrEventRequests: List