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) { - queued += synchronizeNostrEventRequests - } - - override suspend fun observeNostrEventsMatching(synchronizationFilter: SynchronizationFilter): Flow> { - watched += synchronizationFilter - return flowOf(listOf(LocalNostrEvent(nostrEvent = stored, 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) - override suspend fun groupRelayLists(chatRoomId: String): List = emptyList() - } - - val viewModel = CuratedSuggestionListViewModel( - chatRoomId = chatRoomId, - activeUserPublicKey = ada, - relayHint = null, - identifier = "films", - initialCuratedSuggestionListUIState = CuratedSuggestionListUIState.Loading, - nostrRepository = nostrRepository, - chatRepository = chatRepository, - ) - - try { - viewModel.initiate() - - // The read runs on IO and lands on the state when it lands; the - // assertion waits for the rows rather than for a fixed time. - val loaded = runBlocking { - withTimeout(5_000) { - while (true) { - val state = viewModel.curatedSuggestionListUIState - if (state is CuratedSuggestionListUIState.Loaded && state.suggestions.isNotEmpty()) break - delay(20) - } - assertIs(viewModel.curatedSuggestionListUIState) - } - } - - assertEquals(listOf(one.url), loaded.relays) - assertEquals(listOf(one.url), queued.map { it.relayURL }) - assertEquals(listOf(schema.queueReadFilter()), watched) - assertEquals(listOf("Magic Money"), loaded.suggestions.map { it.entry.title }) - assertFalse(loaded.isAsking, "rows arrived and the screen is still looking") - } finally { - // The timer would otherwise run on for ten seconds after the test. - viewModel.viewModelScope.cancel() - } - } - - @Test - fun `a schema this device does not hold is an error rather than an empty queue`() { - 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 = emptyList() - } - - val viewModel = CuratedSuggestionListViewModel( - chatRoomId = chatRoomId, - activeUserPublicKey = ada, - relayHint = null, - identifier = "films", - initialCuratedSuggestionListUIState = CuratedSuggestionListUIState.Loading, - nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, - chatRepository = chatRepository, - ) - - try { - viewModel.initiate() - - runBlocking { - withTimeout(5_000) { - while (viewModel.curatedSuggestionListUIState == CuratedSuggestionListUIState.Loading) delay(20) - } - } - - assertEquals( - CuratedSuggestionListUIState.Error("Couldn't find that schema"), - viewModel.curatedSuggestionListUIState - ) - } finally { - viewModel.viewModelScope.cancel() - } - } - - private fun row(title: String, createdAt: Long) = CuratedSuggestion( - entry = CuratedEntry( - kind = CuratedSchemaEvent.SUGGESTION_KIND, - id = createdAt.toString().padStart(64, 'e'), - pubKey = ada, - createdAt = createdAt, - identifier = title.lowercase().replace(' ', '-'), - title = title, - values = mapOf("title" to listOf(title)), - ), - event = NostrEvent( - id = createdAt.toString().padStart(64, 'e'), - pubKey = ada, - kind = CuratedSchemaEvent.SUGGESTION_KIND, - tags = emptyArray(), - content = "", - sig = "b".repeat(128), - createdAt = Instant.fromEpochSeconds(createdAt), - ), - suggester = null, - isCurated = false, - ) -} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/EditGroupCuratedSchemaViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/EditGroupCuratedSchemaViewModelJvmTest.kt deleted file mode 100644 index 49f8943f..00000000 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/EditGroupCuratedSchemaViewModelJvmTest.kt +++ /dev/null @@ -1,382 +0,0 @@ -package press.mantra.compose.ui.view.model - -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.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 kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNull -import kotlin.test.assertTrue -import kotlin.time.Instant - -/** - * The decisions the schema editor makes that nothing else can check. - * - * **What a new list starts as.** The two fields every list has and the group's - * own relays, so that what the member sees is what the group will sign rather - * than what `CuratedSchema.normalized` will quietly add. - * - * **What cannot be taken away.** A field writing to `d` or `title` cannot be - * removed, because the event would only grow it back -- so the editor has to - * refuse rather than let a member believe they removed it. - * - * **What counts as a change.** The button proposes only when the schema differs - * from the one the group signed, and getting that wrong is quiet in both - * directions: too eager and every visit re-dates a decision the group did not - * make; too shy and an edit is dropped on a screen that said it had been sent. - */ -class EditGroupCuratedSchemaViewModelJvmTest { - private val chatRoomId = "d4c3b2a1".repeat(8) - - private val films = CuratedSchema( - identifier = "films", - title = "Film suggestion", - name = "Films worth translating", - description = "Films the group would subtitle.", - visibility = CuratedVisibility.Closed, - fields = listOf( - CuratedField.IDENTIFIER, - CuratedField.TITLE, - CuratedField("watchUrl", CuratedFieldType.Url, isRequired = false, config = CuratedFieldConfig(tag = "r", marker = "watch")), - CuratedField("imdbUrl", CuratedFieldType.Url, isRequired = false, config = CuratedFieldConfig(tag = "r", marker = "imdb")), - ), - requireAny = listOf(listOf("watchUrl", "imdbUrl")), - suggesters = listOf("e".repeat(64)), - relays = listOf("wss://relay.one.example"), - ) - - private fun viewModel(identifier: String? = null) = EditGroupCuratedSchemaViewModel( - chatRoomId = chatRoomId, - activeUserPublicKey = "a".repeat(64), - relayHint = null, - identifier = identifier, - initialEditGroupCuratedSchemaUIState = EditGroupCuratedSchemaUIState.Loading, - chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, - ) - - private val localChatRoom = LocalChatRoom( - chatRoom = ChatRoom( - id = chatRoomId, - userPublicKey = "a".repeat(64), - subject = "Translation room", - description = null, - initialGiftWrapPayloadId = "sdfaer", - mlsGroupState = "state" - ) - ) - - /** The schema as the group signed it. Not verified here: the editor does not. */ - private fun existing(schema: CuratedSchema = films) = 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 newSchema(defaultRelays: List = listOf("wss://relay.one.example")) = - EditGroupCuratedSchemaUIState.Loaded( - localChatRoom = localChatRoom, - existing = null, - defaultRelays = defaultRelays, - canSign = true, - ) - - private fun editing(schema: CuratedSchema = films) = EditGroupCuratedSchemaUIState.Loaded( - localChatRoom = localChatRoom, - existing = existing(schema), - defaultRelays = listOf("wss://relay.two.example"), - canSign = true, - ) - - @Test - fun `a new schema starts with the two fields every list has and the group's relays`() { - val viewModel = viewModel() - viewModel.seedWorkingCopy(newSchema()) - - assertEquals(listOf("d", "title"), viewModel.fields.map { it.tag() }) - assertTrue(viewModel.fields.all { it.isRequired }) - assertEquals(CuratedVisibility.Public, viewModel.visibility) - assertEquals(listOf("wss://relay.one.example"), viewModel.relays) - assertEquals(emptyList(), viewModel.requireAny) - assertEquals(emptyList(), viewModel.suggesters) - } - - @Test - fun `an existing schema starts as itself, and its relays are its own`() { - val viewModel = viewModel("films") - viewModel.seedWorkingCopy(editing()) - - assertEquals(films.fields, viewModel.fields.toList()) - assertEquals(films.requireAny, viewModel.requireAny.toList()) - assertEquals(films.suggesters, viewModel.suggesters.toList()) - assertEquals(CuratedVisibility.Closed, viewModel.visibility) - // Not the defaults the state also carries: those are for a new list. - assertEquals(listOf("wss://relay.one.example"), viewModel.relays) - } - - @Test - fun `seeding again leaves an edit alone`() { - // The effect that seeds is keyed on a state the view model can re-emit, so - // running twice has to be harmless -- otherwise a reload halfway through an - // edit throws the edit away. - val viewModel = viewModel("films") - viewModel.seedWorkingCopy(editing()) - assertTrue(viewModel.addRelay("wss://relay.two.example")) - - viewModel.seedWorkingCopy(editing()) - - // The added relay is normalized, which is how the group's other relay - // lists are written; the signed one is kept as the group wrote it. - assertEquals(listOf("wss://relay.one.example", "wss://relay.two.example/"), viewModel.relays) - } - - @Test - fun `a relay already signed into the schema is a duplicate with or without the slash`() { - val viewModel = viewModel("films") - viewModel.seedWorkingCopy(editing()) - - assertFalse(viewModel.addRelay("wss://relay.one.example")) - assertFalse(viewModel.addRelay("wss://relay.one.example/")) - assertEquals(EditGroupCuratedSchemaViewModel.AddRelayFailure.AlreadyListed, viewModel.addRelayFailure) - assertEquals(listOf("wss://relay.one.example"), viewModel.relays) - } - - @Test - fun `a mandatory field cannot be removed and another one can`() { - val viewModel = viewModel("films") - viewModel.seedWorkingCopy(editing()) - - assertTrue(viewModel.isMandatory(viewModel.fields[0])) - assertTrue(viewModel.isMandatory(viewModel.fields[1])) - assertFalse(viewModel.isMandatory(viewModel.fields[2])) - - viewModel.removeField(0) - viewModel.removeField(1) - assertEquals(listOf("identifier", "title", "watchUrl", "imdbUrl"), viewModel.fields.map { it.name }) - - // A field that goes takes its place in the rules with it: a rule naming a - // field that no longer exists would silently never be satisfied. - viewModel.removeField(2) - assertEquals(listOf("identifier", "title", "imdbUrl"), viewModel.fields.map { it.name }) - assertEquals(listOf(listOf("imdbUrl")), viewModel.requireAny.toList()) - } - - @Test - fun `renaming a field follows through to the rules that named it`() { - val viewModel = viewModel("films") - viewModel.seedWorkingCopy(editing()) - - viewModel.startEditingField(2) - assertNull(viewModel.commitField(viewModel.fields[2].copy(name = "watch"))) - - assertEquals(listOf("identifier", "title", "watch", "imdbUrl"), viewModel.fields.map { it.name }) - assertEquals(listOf(listOf("watch", "imdbUrl")), viewModel.requireAny.toList()) - assertNull(viewModel.editingField, "the sheet stayed open after a commit") - } - - @Test - fun `a field name is one word and belongs to one field`() { - val viewModel = viewModel("films") - viewModel.seedWorkingCopy(editing()) - - viewModel.startAddingField() - val draft = viewModel.editingField!!.field - - assertEquals(EditGroupCuratedSchemaViewModel.FieldProblem.NameMissing, viewModel.commitField(draft.copy(name = " "))) - assertEquals(EditGroupCuratedSchemaViewModel.FieldProblem.NameHasSpaces, viewModel.commitField(draft.copy(name = "watch url"))) - assertEquals(EditGroupCuratedSchemaViewModel.FieldProblem.NameTaken, viewModel.commitField(draft.copy(name = "imdbUrl"))) - assertEquals(4, viewModel.fields.size, "a refused field went in anyway") - - assertNull(viewModel.commitField(draft.copy(name = " year "))) - assertEquals("year", viewModel.fields.last().name) - - // Editing a field back to its own name is not taking it from anybody. - viewModel.startEditingField(4) - assertNull(viewModel.commitField(viewModel.fields[4].copy(label = "Year"))) - } - - @Test - fun `a rule with fewer than two names is dropped from the draft rather than refused`() { - val viewModel = viewModel() - viewModel.seedWorkingCopy(newSchema()) - - viewModel.addRequireAnyGroup() - viewModel.toggleRequireAny(0, "title") - viewModel.addRequireAnyGroup() - - val draft = viewModel.draft("d", "t", "n", "desc", "", "") - - assertEquals(2, viewModel.requireAny.size, "the editor lost a rule being built") - assertEquals(emptyList(), draft.requireAny) - } - - @Test - fun `a pubkey is an npub or hex and nothing else`() { - val hex = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860" - // Encoded rather than typed, so the checksum is right by construction. - val npub = hex.hexToNpubHrp() - - assertEquals(hex, EditGroupCuratedSchemaViewModel.pubkeyOrNull(hex)) - assertEquals(hex, EditGroupCuratedSchemaViewModel.pubkeyOrNull(" ${hex.uppercase()} ")) - assertEquals(hex, EditGroupCuratedSchemaViewModel.pubkeyOrNull(npub)) - assertEquals(hex, EditGroupCuratedSchemaViewModel.pubkeyOrNull("nostr:$npub")) - - listOf("", "npub1notanpub", hex.dropLast(1), "group@example.com", "nsec1" + npub.drop(5)).forEach { - assertNull(EditGroupCuratedSchemaViewModel.pubkeyOrNull(it), "\"$it\" was taken for a pubkey") - } - - val viewModel = viewModel() - viewModel.seedWorkingCopy(newSchema()) - assertFalse(viewModel.addSuggester("group@example.com")) - assertEquals(EditGroupCuratedSchemaViewModel.AddSuggesterFailure.NotAPubkey, viewModel.addSuggesterFailure) - assertTrue(viewModel.addSuggester(npub)) - assertFalse(viewModel.addSuggester(hex)) - assertEquals(EditGroupCuratedSchemaViewModel.AddSuggesterFailure.AlreadyListed, viewModel.addSuggesterFailure) - assertEquals(listOf(hex), viewModel.suggesters) - } - - @Test - fun `a url that is not a relay is refused and named`() { - val viewModel = viewModel() - viewModel.seedWorkingCopy(newSchema(defaultRelays = emptyList())) - - assertFalse(viewModel.addRelay("http://relay.one.example")) - assertEquals(EditGroupCuratedSchemaViewModel.AddRelayFailure.NotARelay, viewModel.addRelayFailure) - // The NIP allows ws://; what the group signs is held to the app's rule. - assertFalse(viewModel.addRelay("ws://relay.one.example")) - - assertTrue(viewModel.addRelay("wss://relay.one.example")) - assertFalse(viewModel.addRelay("wss://relay.one.example")) - assertEquals(EditGroupCuratedSchemaViewModel.AddRelayFailure.AlreadyListed, viewModel.addRelayFailure) - } - - @Test - fun `opening a schema and pressing the button proposes nothing`() { - // The case that has to be silent. A member opening the editor, reading it - // and pressing the button must not spend a quorum on an identical schema. - val viewModel = viewModel("films") - viewModel.seedWorkingCopy(editing()) - - var outcome = "" - viewModel.proposeSchema( - localChatRoom = localChatRoom, - existing = existing(), - identifier = films.identifier, - title = films.title, - name = films.name, - description = films.description, - picture = "", - domain = "", - onSuccess = { outcome = "proposed" }, - onInvalid = { outcome = "invalid" }, - onNothingToPropose = { outcome = "nothing" }, - onFailure = { outcome = "failed" }, - ) - - assertEquals("nothing", outcome) - assertEquals(emptyList(), viewModel.problems) - } - - @Test - fun `an edit keeps the identifier whatever the field says`() { - val viewModel = viewModel("films") - viewModel.seedWorkingCopy(editing()) - - var outcome = "" - viewModel.proposeSchema( - localChatRoom = localChatRoom, - existing = existing(), - // A different identifier would be a different list, not an edit. - identifier = "books", - title = films.title, - name = films.name, - description = films.description, - picture = "", - domain = "", - onSuccess = { outcome = "proposed" }, - onInvalid = { outcome = "invalid" }, - onNothingToPropose = { outcome = "nothing" }, - onFailure = { outcome = "failed" }, - ) - - assertEquals("nothing", outcome, "a changed identifier was taken as an edit") - } - - @Test - fun `a schema a reader would refuse is refused here, and the reasons are kept`() { - val viewModel = viewModel() - viewModel.seedWorkingCopy(newSchema()) - - var outcome = "" - viewModel.proposeSchema( - localChatRoom = localChatRoom, - existing = null, - identifier = " ", - title = "t".repeat(201), - name = "Films", - description = "", - picture = "http://example.com/x.png", - domain = "not a domain", - onSuccess = { outcome = "proposed" }, - onInvalid = { outcome = "invalid" }, - onNothingToPropose = { outcome = "nothing" }, - onFailure = { outcome = "failed" }, - ) - - assertEquals("invalid", outcome) - assertEquals( - listOf( - CuratedSchemaProblem.IdentifierMissing, - CuratedSchemaProblem.TitleTooLong, - CuratedSchemaProblem.DescriptionMissing, - CuratedSchemaProblem.PictureNotHttps, - CuratedSchemaProblem.DomainInvalid, - ), - viewModel.problems, - ) - } - - @Test - fun `the draft trims what was typed and normalizes the domain`() { - val viewModel = viewModel() - viewModel.seedWorkingCopy(newSchema()) - - val draft = viewModel.draft( - identifier = " films ", - title = " Film suggestion ", - name = " Films ", - description = " Films. ", - picture = " ", - domain = " https://Example.COM/ ", - ) - - assertEquals("films", draft.identifier) - assertEquals("Film suggestion", draft.title) - assertEquals("Films", draft.name) - assertEquals("Films.", draft.description) - assertNull(draft.picture) - assertEquals("example.com", draft.domain) - assertTrue(draft.isUsable()) - } -} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/EditGroupRelaysViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/EditGroupRelaysViewModelJvmTest.kt deleted file mode 100644 index 91ac1332..00000000 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/EditGroupRelaysViewModelJvmTest.kt +++ /dev/null @@ -1,224 +0,0 @@ -package press.mantra.compose.ui.view.model - -import press.mantra.compose.database.model.GroupSignedEvent -import press.mantra.compose.nostr.GroupRelay -import press.mantra.compose.nostr.GroupRelayList -import press.mantra.compose.nostr.GroupRelaySet -import press.mantra.compose.nostr.Relays -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.repository.FrostSigningRepository -import press.mantra.compose.ui.view.state.EditGroupRelaysUIState -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue -import kotlin.time.Instant - -/** - * The two decisions the relay editor makes that nothing else can check. - * - * **What counts as a change.** The button proposes only the lists that differ, so - * this is what decides whether a quorum is asked at all -- and getting it wrong in - * either direction is quiet. Too eager and every visit re-signs four lists nobody - * touched, dating a decision the group did not make; too shy and an edit is - * silently dropped on a screen that said it had been sent. - * - * **What a NIP-65 marker is allowed to become.** A relay that is neither read nor - * write has no tag in the format, so the state must not be reachable. - * `GroupRelaySet.template` drops one as a last resort; this is where it is actually - * prevented. - */ -class EditGroupRelaysViewModelJvmTest { - private val chatRoomId = "d4c3b2a1".repeat(8) - - private val one = RelayUrlNormalizer.normalize("wss://relay.one.example") - private val two = RelayUrlNormalizer.normalize("wss://relay.two.example") - - private fun viewModel() = EditGroupRelaysViewModel( - chatRoomId = chatRoomId, - activeUserPublicKey = "a".repeat(64), - relayHint = null, - initialEditGroupRelaysUIState = EditGroupRelaysUIState.Loading, - chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, - ) - - private fun signedEvent(set: GroupRelaySet) = GroupSignedEvent( - id = "a" + set.kind.toString().padStart(5, '0') + "0".repeat(58), - chatRoomId = chatRoomId, - publicKey = chatRoomId, - kind = set.kind, - tags = emptyArray(), - content = "", - signature = "f".repeat(128), - createdAt = Instant.fromEpochSeconds(1_700_000_000), - ) - - /** Every set unsigned, which is a group opening the editor for the first time. */ - private fun nothingSigned() = GroupRelayList.allAmong(emptyList(), chatRoomId) - - private fun signed(set: GroupRelaySet, relays: List) = - nothingSigned().map { - if (it.set == set) GroupRelayList(set, signedEvent(set), relays) else it - } - - @Test - fun `a first-time group is seeded with the app's own relay and that is a change`() { - val signed = nothingSigned() - val viewModel = viewModel() - viewModel.seedWorkingCopy(signed) - - assertEquals( - listOf(Relays.ephemeral), - viewModel.relaysOf(GroupRelaySet.General).map { it.url }, - ) - - // Seeded and therefore proposable. A group whose editor filled itself in - // and then refused to send it would be showing a plan it will not carry out. - assertEquals( - listOf(GroupRelaySet.General, GroupRelaySet.Messages, GroupRelaySet.Search), - viewModel.changedSets(signed), - ) - } - - @Test - fun `blocked is never seeded and so is never proposed by simply opening the screen`() { - val signed = nothingSigned() - val viewModel = viewModel() - viewModel.seedWorkingCopy(signed) - - assertEquals(emptyList(), viewModel.relaysOf(GroupRelaySet.Blocked)) - assertFalse(GroupRelaySet.Blocked in viewModel.changedSets(signed)) - } - - @Test - fun `opening a group that has agreed everything proposes nothing`() { - // The case that has to be silent. A member opening the editor, reading it - // and pressing the button must not spend a quorum on four identical lists. - val signed = GroupRelaySet.entries.map { set -> - GroupRelayList(set, signedEvent(set), listOf(GroupRelay(one))) - } - - val viewModel = viewModel() - viewModel.seedWorkingCopy(signed) - - assertEquals(emptyList(), viewModel.changedSets(signed)) - } - - @Test - fun `only the set that was edited is proposed`() { - val signed = GroupRelaySet.entries.map { set -> - GroupRelayList(set, signedEvent(set), listOf(GroupRelay(one))) - } - - val viewModel = viewModel() - viewModel.seedWorkingCopy(signed) - viewModel.selectSet(GroupRelaySet.Search) - assertTrue(viewModel.addRelay(two.url)) - - assertEquals(listOf(GroupRelaySet.Search), viewModel.changedSets(signed)) - } - - @Test - fun `reordering a list is a change`() { - // A relay list is written in the order it is read back, and a member who - // moved a relay to the front meant to. Comparing as sets would call this no - // change and refuse to propose it. - val signed = signed(GroupRelaySet.General, listOf(GroupRelay(one), GroupRelay(two))) - - val viewModel = viewModel() - viewModel.seedWorkingCopy(signed) - viewModel.selectSet(GroupRelaySet.General) - viewModel.removeRelay(GroupRelaySet.General, GroupRelay(one)) - assertTrue(viewModel.addRelay(one.url)) - - assertEquals( - listOf(two, one), - viewModel.relaysOf(GroupRelaySet.General).map { it.url }, - ) - assertTrue(GroupRelaySet.General in viewModel.changedSets(signed)) - } - - @Test - fun `emptying an agreed list is a change, and emptying an unagreed one is not`() { - // Withdrawing a list the group agreed is a decision worth a signature. - // "Still nothing" is not a decision at all, and proposing an empty list for - // a set nobody ever set would be a signature over silence. - val agreed = signed(GroupRelaySet.Messages, listOf(GroupRelay(one))) - val withdrawing = viewModel() - withdrawing.seedWorkingCopy(agreed) - withdrawing.removeRelay(GroupRelaySet.Messages, GroupRelay(one)) - - assertTrue(GroupRelaySet.Messages in withdrawing.changedSets(agreed)) - - val untouched = nothingSigned() - val quiet = viewModel() - quiet.seedWorkingCopy(untouched) - // Clear the seeded default so the set is unagreed *and* empty. - quiet.removeRelay(GroupRelaySet.Search, GroupRelay(Relays.ephemeral)) - - assertFalse(GroupRelaySet.Search in quiet.changedSets(untouched)) - } - - @Test - fun `a relay cannot be turned into one that is neither read nor write`() { - // NIP-65 has no tag for it, so the state has to be unreachable. What it - // would mean is that the relay is not in the list, and removing it is the - // row's own button. - val signed = signed(GroupRelaySet.General, listOf(GroupRelay(one))) - val viewModel = viewModel() - viewModel.seedWorkingCopy(signed) - - viewModel.toggleWrite(GroupRelaySet.General, GroupRelay(one)) - assertEquals( - listOf(GroupRelay(one, read = true, write = false)), - viewModel.relaysOf(GroupRelaySet.General), - ) - - // The second toggle is the one that would leave it as neither. - viewModel.toggleRead( - GroupRelaySet.General, - viewModel.relaysOf(GroupRelaySet.General).first(), - ) - assertEquals( - listOf(GroupRelay(one, read = true, write = false)), - viewModel.relaysOf(GroupRelaySet.General), - "a relay was left neither read nor write, which NIP-65 cannot express", - ) - } - - @Test - fun `a url that is not a relay is refused and named`() { - val viewModel = viewModel() - viewModel.seedWorkingCopy(nothingSigned()) - viewModel.selectSet(GroupRelaySet.Search) - - assertFalse(viewModel.addRelay("http://relay.one.example")) - assertEquals(EditGroupRelaysViewModel.AddFailure.NotARelay, viewModel.addFailure) - - // And a duplicate is a no-op rather than a mistake, said differently. - assertTrue(viewModel.addRelay(one.url)) - assertFalse(viewModel.addRelay(one.url)) - assertEquals(EditGroupRelaysViewModel.AddFailure.AlreadyListed, viewModel.addFailure) - } - - @Test - fun `seeding again leaves an edit alone`() { - // The effect that seeds is keyed on a state the view model can re-emit, so - // running twice has to be harmless -- otherwise a reload halfway through - // typing throws the typing away. - val signed = signed(GroupRelaySet.General, listOf(GroupRelay(one))) - val viewModel = viewModel() - viewModel.seedWorkingCopy(signed) - viewModel.selectSet(GroupRelaySet.General) - assertTrue(viewModel.addRelay(two.url)) - - viewModel.seedWorkingCopy(signed) - - assertEquals( - listOf(one, two), - viewModel.relaysOf(GroupRelaySet.General).map { it.url }, - ) - } -} diff --git a/docs/README.md b/docs/README.md index 6e93b973..6f61a60d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -40,8 +40,10 @@ only one about what the app looks like rather than what it does; read the jvm-target note first if you want to know why its adaptive-layout phase exists. The curated-to-mantra note is a phased plan that has been built, save for its last phase, which is a decision: it is about the repository rather than the app, and reads -alone, except that its first decision leans on the derivation note's one rule. The -nsec and npub sign-in notes arrived with it and record what they built. +alone, except that its first decision leans on the derivation note's one rule. Two of +the lines it pulled -- the group's nostr identity and the curated lists -- were taken +out again the same day, and its record says what went and what stayed. The nsec and +npub sign-in notes arrived with it and record what they built. The nsec sign-in note is a phased plan that has been built; it inherits the key-storage decision from the jvm-target note and drives the navigation state machine `NavigationViewModel.processLocalAccount` implements, so read it with the diff --git a/docs/curated-to-mantra.md b/docs/curated-to-mantra.md index 21629146..0836f46f 100644 --- a/docs/curated-to-mantra.md +++ b/docs/curated-to-mantra.md @@ -32,6 +32,31 @@ says so, and it did so in seven places worth reading: | the exactness diff is the logo plus `808a3459`'s three files | plus the fourteen files Mantra changed on its own side: Phase 0's prose at the protected names, `39fb64b6`'s four, and this document with its two scripts. Seventeen files, every one accounted for, at every cut | | 1,036 jvm tests at the end | 1,039 — the three from `39fb64b6` — and per phase 736, 864, 905, 1,007, 1,039; `testDebugUnitTest` 403, 486, 495, 530, 530; every phase clean on both compilers with every audit budget met | +**Lines B and D were taken out again the same day**, and H with them, by a product +decision rather than a merge problem: the group's nostr profile, its relay lists, its +posts, the curated schemas and the curated entries are not Mantra's product, and the +[third decision](#3-curated-lists-are-not-mantras-product-and-mantra-should-take-them-anyway)'s +"hide the two rows behind a constant" was not enough once the profile, relays and +posts rows went with them. What went: the five rows and the twelve screens behind +them, `nostr/curated/`, the readings (`GroupNostrProfile`, `GroupRelayList`, +`GroupPost`, `GroupCuratedSchema`, `GroupCuratedEntry`, `CuratedSuggestion`), the +`applyInnerEvent` arms for kinds 0, 1, the four relay-list kinds, 31889 and 31890, +the `ChatRepository` and `NostrRepository` reads that fed them, the pasted-event +proposal (`GroupEventProposal`, whose accepted kinds were exactly those), the +`ProposedEvent` summaries for the same kinds, 226 strings, and every test that came +with them. What stayed from the pull: F (the back button), G and I (sign-in), J (the +library chore), and E (broadcast) -- which nothing opens now, since its button sat on +the five removed screens and its relay seeding read the group's relay lists and +schemas; `defaultRelaysFor` is the app's publish set, and the route and the button +are kept against the day something wires one in. Every pre-existing file the removed +commits touched is back at its pre-pull content plus the kept lines' hunks; the +exactness check for that is `git diff ba0830a3 -- ` showing only the back +button, broadcast's `groupSignedEvent`, and the sign-in reads. Tests afterwards: 826 +jvm, 413 common, every audit budget met. The seam this leaves for the next pull is the +one the third decision predicted: any upstream commit that touches the identity block +will conflict at `ChatRoomDetailScreen`, `MantraNavHost` and `strings.xml`, and the +resolution is to drop it. + The upstream moved before this landed: `curated/curated` went from `86cb876b` to `29027f2b`, ten commits for several profiles on one device, one of them the fork's first schema migration (version 20). They are the next pull, and the schema change is diff --git a/docs/scripts/m3-title-case.py b/docs/scripts/m3-title-case.py index 51f154c5..02a4c11a 100755 --- a/docs/scripts/m3-title-case.py +++ b/docs/scripts/m3-title-case.py @@ -29,8 +29,6 @@ SMALL = {'a', 'an', 'the', 'to', 'of', 'for', 'and', 'or', 'via', SAMPLE = { 'Steve Biko', 'John Doe', 'Frank Talk', 'Alan Turing', 'To Kill a Mockingbird', 'Man With A Plan', 'Woman Of Few Words', - # Films, as suggested to a curated list in the queue screen's preview. - 'The Rise and Rise of Bitcoin', 'Magic Money', }