diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index f74dd05c..9244b7d6 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -394,6 +394,8 @@
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
@@ -417,6 +419,8 @@
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.
@@ -444,6 +448,10 @@
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.
@@ -453,6 +461,10 @@
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
@@ -614,6 +626,10 @@
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
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 b3fdf441..070e548a 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/extensions/Member.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/extensions/Member.kt
@@ -9,6 +9,23 @@ 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/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt
index b2148a64..c6475bf2 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
@@ -1,11 +1,7 @@
package press.mantra.compose.ui.composable
import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.ExperimentalLayoutApi
-import androidx.compose.foundation.layout.FlowRow
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
@@ -19,17 +15,13 @@ import androidx.compose.material.icons.Icons
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.CheckCircle
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.Edit
-import androidx.compose.material.icons.filled.Inbox
-import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
import androidx.compose.material.icons.filled.Campaign
-import androidx.compose.material.icons.filled.CellTower
import androidx.compose.material.icons.filled.DataObject
import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.Draw
@@ -63,6 +55,7 @@ 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
@@ -83,16 +76,15 @@ 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.AddGroupPostRoute
-import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute
-import press.mantra.compose.ui.composable.navigation.routes.EditGroupCuratedSchemaRoute
-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.EditGroupNostrProfileRoute
-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.ProposalListRoute
import press.mantra.compose.ui.composable.navigation.routes.SearchMemberToAddToChatRoomRoute
-import press.mantra.compose.extensions.shortened
+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
@@ -113,31 +105,21 @@ 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.add_post
-import mantra.composeapp.generated.resources.a_reply
import mantra.composeapp.generated.resources.curated_entries
import mantra.composeapp.generated.resources.curated_schemas
-import mantra.composeapp.generated.resources.in_list
-import mantra.composeapp.generated.resources.suggested_by
-import mantra.composeapp.generated.resources.this_group_has_not_accepted_anything_into_a_list_yet
-import mantra.composeapp.generated.resources.this_group_has_not_published_a_curated_schema_yet
-import mantra.composeapp.generated.resources.add_schema
-import mantra.composeapp.generated.resources.view_suggestions
-import mantra.composeapp.generated.resources.fields_colon
+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.posted_on
-import mantra.composeapp.generated.resources.this_group_has_not_posted_anything_yet
-import mantra.composeapp.generated.resources.edit_relays
-import mantra.composeapp.generated.resources.not_set_yet
-import mantra.composeapp.generated.resources.no_relays_in_this_list
-import mantra.composeapp.generated.resources.relay_set_general
-import mantra.composeapp.generated.resources.relay_set_messages
-import mantra.composeapp.generated.resources.relay_set_search
-import mantra.composeapp.generated.resources.relay_set_blocked
import mantra.composeapp.generated.resources.no_name_on_this_profile
-import mantra.composeapp.generated.resources.edit_nostr_profile
-import mantra.composeapp.generated.resources.signed_by_the_group_on
-import mantra.composeapp.generated.resources.this_group_has_not_said_anything_about_itself
import mantra.composeapp.generated.resources.parent_group
import mantra.composeapp.generated.resources.a_subgroup
import mantra.composeapp.generated.resources.created_you_are_not_a_member
@@ -149,11 +131,7 @@ 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.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.nostr.GroupRelaySet
import mantra.composeapp.generated.resources.Res
import org.jetbrains.compose.resources.stringResource
@@ -182,7 +160,6 @@ import mantra.composeapp.generated.resources.this_room_signs_as
import mantra.composeapp.generated.resources.derivation_path
import mantra.composeapp.generated.resources.key_ceremony
import mantra.composeapp.generated.resources.agreed_on
-import mantra.composeapp.generated.resources.broadcast
import mantra.composeapp.generated.resources.the_signed_event
import mantra.composeapp.generated.resources.copy_the_signed_event
import mantra.composeapp.generated.resources.copied_the_signed_event
@@ -198,7 +175,7 @@ import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
import press.mantra.compose.ui.theme.ConformancePreviews
import kotlin.time.Instant
-@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
+@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
fun ChatRoomDetailScreen(
activeUserPublicKey: HexKey,
@@ -256,19 +233,6 @@ fun ChatRoomDetailScreen(
) {
var isShowingGroupKeyState by rememberSaveable { mutableStateOf(false) }
- // The one way into the broadcast screen, for every signed
- // event in the identity block below.
- val broadcast: (GroupSignedEvent) -> Unit = { signedEvent ->
- onNavigateToRoute.invoke(
- BroadcastGroupSignedEventRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = relayHint,
- signedEventId = signedEvent.id
- )
- )
- }
-
LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth().padding(MaterialTheme.spacing.space250),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -395,130 +359,81 @@ fun ChatRoomDetailScreen(
// 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
- // section saying it has not described one yet would
- // promise something that is never coming.
+ // row saying it has not described one yet would promise
+ // something that is never coming.
if (chatRoomDetailUIState.localChatRoom.chatRoom.mlsGroupState != null) {
item {
- Text(
- text = stringResource(Res.string.nostr_profile),
- style = MaterialTheme.typography.labelMedium
- )
- }
-
- item {
- chatRoomDetailUIState.groupNostrProfile?.let { groupNostrProfile ->
- // 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
- )
-
- // Behind no gate, here and under every
- // other signed event in the block: sending
- // what the group has already signed takes
- // no share of the key, since the
- // signature on it is the group's whoever
- // repeats it. Only *making* a statement
- // is gated.
- BroadcastButton(
- onClick = { broadcast(groupNostrProfile.signedEvent) }
- )
- }
- } ?: Text(
- stringResource(
- Res.string.this_group_has_not_said_anything_about_itself
- )
- )
- }
-
- // The profile is worth reading either way; proposing
- // one is a signature by the group, and
- // `proposeSigningBatch` throws for a device holding
- // no share of the key. Hidden rather than disabled,
- // the way the subgroup entry is.
- if (chatRoomDetailUIState.canEditNostrProfile) {
- item {
- TextButton(
- onClick = {
- onNavigateToRoute.invoke(
- EditGroupNostrProfileRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = relayHint
- )
- )
+ 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(" · ")
}
- ) {
- Icon(
- Icons.Default.Badge,
- contentDescription = Decorative
- )
-
- Spacer(
- modifier = Modifier.width(
- MaterialTheme.spacing.space125
+ ?: stringResource(Res.string.not_described_yet),
+ onOpen = {
+ onNavigateToRoute.invoke(
+ GroupNostrProfileRoute(
+ activeUserPublicKey = activeUserPublicKey,
+ chatRoomId = chatRoomId,
+ relayHint = relayHint
)
)
-
- Text(stringResource(Res.string.edit_nostr_profile))
}
- }
+ )
}
// Directly under the profile, because between them
// they are the whole of the group's account of
// itself on nostr: who it says it is, and where it
- // says it can be found. Same gate, same reasons.
+ // says it can be found.
item {
- Text(
- text = stringResource(Res.string.relays),
- style = MaterialTheme.typography.labelMedium
- )
- }
-
- item {
- GroupRelayListsCard(
- relayLists = chatRoomDetailUIState.groupRelayLists,
- onBroadcast = broadcast
- )
- }
-
- if (chatRoomDetailUIState.canEditNostrProfile) {
- item {
- TextButton(
- onClick = {
- onNavigateToRoute.invoke(
- EditGroupRelaysRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = relayHint
- )
- )
- }
- ) {
- Icon(
- Icons.Default.Dns,
- contentDescription = Decorative
- )
-
- Spacer(
- modifier = Modifier.width(
- MaterialTheme.spacing.space125
+ 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
)
)
-
- Text(stringResource(Res.string.edit_relays))
}
- }
+ )
}
// Under the relay lists, because these are what the
@@ -528,213 +443,53 @@ fun ChatRoomDetailScreen(
// library, which is the group's work rather than its
// voice.
item {
- Text(
- text = stringResource(Res.string.posts),
- style = MaterialTheme.typography.labelMedium
+ 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
+ )
+ )
+ }
)
}
- if (chatRoomDetailUIState.groupPosts.isEmpty()) {
- item {
- Text(
- stringResource(
- Res.string.this_group_has_not_posted_anything_yet
- )
- )
- }
- } else {
- items(
- items = chatRoomDetailUIState.groupPosts,
- key = { post -> post.signedEvent.id }
- ) { post ->
- Column(
- modifier = Modifier.fillMaxWidth(),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- GroupPostCard(
- post = post,
- publicKey = chatRoomId,
- groupNostrProfile =
- chatRoomDetailUIState.groupNostrProfile
- )
-
- BroadcastButton(
- onClick = { broadcast(post.signedEvent) }
- )
- }
- }
- }
-
- if (chatRoomDetailUIState.canEditNostrProfile) {
- item {
- TextButton(
- onClick = {
- onNavigateToRoute.invoke(
- AddGroupPostRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = relayHint
- )
- )
- }
- ) {
- Icon(
- Icons.Default.Campaign,
- contentDescription = Decorative
- )
-
- Spacer(
- modifier = Modifier.width(
- MaterialTheme.spacing.space125
- )
- )
-
- Text(stringResource(Res.string.add_post))
- }
- }
- }
-
// 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 section below.
+ // it curated follows, in the row below.
item {
- Text(
- text = stringResource(Res.string.curated_schemas),
- style = MaterialTheme.typography.labelMedium
+ 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
+ )
+ )
+ }
)
}
- if (chatRoomDetailUIState.groupCuratedSchemas.isEmpty()) {
- item {
- Text(
- stringResource(
- Res.string.this_group_has_not_published_a_curated_schema_yet
- )
- )
- }
- } else {
- items(
- items = chatRoomDetailUIState.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 same
- // gate as every button in this block,
- // applied to the card because each
- // schema is edited on its own.
- onEdit = if (chatRoomDetailUIState.canEditNostrProfile) {
- {
- onNavigateToRoute.invoke(
- EditGroupCuratedSchemaRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = relayHint,
- identifier = 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 = { broadcast(curated.signedEvent) }
- )
- }
- }
- }
- }
-
- if (chatRoomDetailUIState.canEditNostrProfile) {
- item {
- TextButton(
- onClick = {
- onNavigateToRoute.invoke(
- EditGroupCuratedSchemaRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = relayHint,
- identifier = null
- )
- )
- }
- ) {
- Icon(
- Icons.AutoMirrored.Filled.PlaylistAdd,
- contentDescription = Decorative
- )
-
- Spacer(
- modifier = Modifier.width(
- MaterialTheme.spacing.space125
- )
- )
-
- Text(stringResource(Res.string.add_schema))
- }
- }
- }
-
// Under the schemas, because these are what a
// schema is *for*: the schema asks, strangers
// answer on the queue, and these are the answers
@@ -744,57 +499,35 @@ fun ChatRoomDetailScreen(
// never in the room, and an accepted entry is the
// last thing in that chain.
item {
- Text(
- text = stringResource(Res.string.curated_entries),
- style = MaterialTheme.typography.labelMedium
+ 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
+ )
+ )
+ }
)
}
- if (chatRoomDetailUIState.groupCuratedEntries.isEmpty()) {
- item {
- Text(
- stringResource(
- Res.string.this_group_has_not_accepted_anything_into_a_list_yet
- )
- )
- }
- } else {
- items(
- items = chatRoomDetailUIState.groupCuratedEntries,
- key = { accepted -> accepted.signedEvent.id }
- ) { accepted ->
- // The card and its broadcast as one item,
- // so the button stays under the entry it
- // sends. No gate on either: 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, on the schema's card
- // above.
- Column(
- modifier = Modifier.fillMaxWidth(),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- GroupCuratedEntryCard(accepted = accepted)
-
- BroadcastButton(
- onClick = { broadcast(accepted.signedEvent) }
- )
- }
- }
- }
-
// The untyped way into everything above it. Each
- // section has 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 in whichever
- // section 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 section of it, and
- // behind the same gate for the same reason.
+ // 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(
@@ -1557,419 +1290,39 @@ internal fun GroupKeyStateSheetContent(
}
/**
- * A long hex value in groups of eight, which is what makes it readable and what
- * lets it wrap at all.
+ * 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.
*
- * 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.
- */
-/**
- * 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.
+ * 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.
*/
@Composable
-private fun GroupNostrProfileCard(
- groupNostrProfile: GroupNostrProfile,
- publicKey: HexKey
+private fun SignedWorkRow(
+ icon: ImageVector,
+ headline: String,
+ supporting: String,
+ onOpen: () -> Unit
) {
- Card(modifier = Modifier.fillMaxWidth()) {
+ Card(modifier = Modifier.fillMaxWidth(), onClick = onOpen) {
ListItem(
leadingContent = {
- ProfileAvatar(
- publicKey = publicKey,
- picture = groupNostrProfile.picture(),
- name = groupNostrProfile.name()
- )
+ Icon(icon, contentDescription = Decorative)
+ },
+ trailingContent = {
+ Icon(Icons.Default.ChevronRight, contentDescription = Decorative)
},
headlineContent = {
- Text(
- // Named rather than falling back to the pubkey: the signing key
- // row above already shows that, and showing it twice would say
- // the group has a name when it has not.
- text = groupNostrProfile.name()
- ?: stringResource(Res.string.no_name_on_this_profile),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
+ Text(text = headline)
},
supportingContent = {
- Column(
- verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)
- ) {
- groupNostrProfile.nip05()?.let {
- Text(
- text = it,
- style = MaterialTheme.typography.labelMedium,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- }
-
- groupNostrProfile.about()?.let {
- Text(
- text = it,
- maxLines = 3,
- overflow = TextOverflow.Ellipsis
- )
- }
-
- Text(
- text = stringResource(
- Res.string.signed_by_the_group_on,
- groupNostrProfile.signedAt.toFormattedTimeAndDateString()
- ),
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- }
- )
- }
-}
-
-/**
- * Where the group has said it lives on nostr, one line per relay list.
- *
- * One card holding four rows rather than four cards: they are read together -- the
- * question is whether the group is reachable, not what any single list says -- and
- * four cards of one line each on a screen this long is four dividers to scroll past
- * for the same four facts.
- *
- * **A list the group has never agreed reads "not set yet", and an agreed empty one
- * reads "no relays".** Those are different things: one is a group that has not got
- * round to it, and the other is a group that decided. Collapsing them would hide a
- * deliberate withdrawal behind an oversight.
- */
-@Composable
-private fun GroupRelayListsCard(
- relayLists: List,
- 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 way into `BroadcastGroupSignedEventScreen`, drawn the same under every
- * signed event in the identity block so that it reads as one action wherever it
- * sits. The icon is decorative because the label is beside it.
- */
-@Composable
-private fun BroadcastButton(onClick: () -> Unit) {
- TextButton(onClick = onClick) {
- Icon(Icons.Default.CellTower, contentDescription = Decorative)
-
- Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125))
-
- Text(stringResource(Res.string.broadcast))
- }
-}
-
-/** The name of a relay list, for the summary on the group's screen. */
-private fun GroupRelaySet.summaryLabel() = when (this) {
- GroupRelaySet.General -> Res.string.relay_set_general
- GroupRelaySet.Messages -> Res.string.relay_set_messages
- GroupRelaySet.Search -> Res.string.relay_set_search
- GroupRelaySet.Blocked -> Res.string.relay_set_blocked
-}
-
-/**
- * One 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
- )
- }
- }
-}
-
-/**
- * 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 section'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() }
- }
-}
-
-/**
- * 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
- * block 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,
+ text = supporting,
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
- )
- }
}
)
}
@@ -2025,8 +1378,6 @@ 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(
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
new file mode 100644
index 00000000..7edd477e
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupCuratedEntriesScreen.kt
@@ -0,0 +1,365 @@
+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
new file mode 100644
index 00000000..5d2f74e7
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupCuratedSchemasScreen.kt
@@ -0,0 +1,442 @@
+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
new file mode 100644
index 00000000..f60b7414
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileScreen.kt
@@ -0,0 +1,353 @@
+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
new file mode 100644
index 00000000..bac3ebd6
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupPostsScreen.kt
@@ -0,0 +1,352 @@
+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
new file mode 100644
index 00000000..febfa89a
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupRelaysScreen.kt
@@ -0,0 +1,327 @@
+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/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt
index 9dbcedd5..532f3200 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
@@ -48,6 +48,11 @@ 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
@@ -140,6 +145,11 @@ import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSigned
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
@@ -1080,6 +1090,86 @@ 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()
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
new file mode 100644
index 00000000..e70359a3
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupCuratedEntriesRoute.kt
@@ -0,0 +1,16 @@
+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
new file mode 100644
index 00000000..ab698bf9
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupCuratedSchemasRoute.kt
@@ -0,0 +1,16 @@
+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
new file mode 100644
index 00000000..c88e74a1
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupNostrProfileRoute.kt
@@ -0,0 +1,19 @@
+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
new file mode 100644
index 00000000..da9a5623
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupPostsRoute.kt
@@ -0,0 +1,15 @@
+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
new file mode 100644
index 00000000..dfa88279
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupRelaysRoute.kt
@@ -0,0 +1,15 @@
+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/widgets/buttons/BroadcastButton.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/buttons/BroadcastButton.kt
new file mode 100644
index 00000000..3912ff8b
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/buttons/BroadcastButton.kt
@@ -0,0 +1,38 @@
+package press.mantra.compose.ui.composable.widgets.buttons
+
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CellTower
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import mantra.composeapp.generated.resources.Res
+import mantra.composeapp.generated.resources.broadcast
+import org.jetbrains.compose.resources.stringResource
+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.
+ *
+ * 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
+ * repeats it. Only *making* a statement is gated, and that is the caller's
+ * concern. The icon is decorative because the label is beside it.
+ */
+@Composable
+fun BroadcastButton(onClick: () -> Unit) {
+ TextButton(onClick = onClick) {
+ Icon(Icons.Default.CellTower, contentDescription = Decorative)
+
+ Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125))
+
+ Text(stringResource(Res.string.broadcast))
+ }
+}
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
new file mode 100644
index 00000000..b9412b92
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupCuratedEntriesViewModel.kt
@@ -0,0 +1,65 @@
+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
new file mode 100644
index 00000000..e3988d9b
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupCuratedSchemasViewModel.kt
@@ -0,0 +1,66 @@
+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
new file mode 100644
index 00000000..2d7b4315
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupNostrProfileViewModel.kt
@@ -0,0 +1,71 @@
+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
new file mode 100644
index 00000000..20fae7f9
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupPostsViewModel.kt
@@ -0,0 +1,64 @@
+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
new file mode 100644
index 00000000..8c6c4152
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupRelaysViewModel.kt
@@ -0,0 +1,62 @@
+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/state/ChatRoomDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt
index efc4e4af..7222beae 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
@@ -59,11 +59,12 @@ sealed interface ChatRoomDetailUIState {
* The kind:0 the group signed about itself, or null while it has signed
* none.
*
- * Sits under the room's identity and above its work, because that is what
- * it is: the name and the picture the rest of nostr sees on the key the
- * signing key row names. Null is an ordinary state and the screen says so
- * -- a group has an identity from the moment it has a key and describes it
- * whenever somebody gets round to it.
+ * 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,
/**
@@ -71,7 +72,8 @@ sealed interface ChatRoomDetailUIState {
*
* 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.
+ * 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`.
@@ -82,8 +84,9 @@ sealed interface ChatRoomDetailUIState {
*
* 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. Empty in a group that has said
- * nothing, which is every group until somebody proposes one.
+ * 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(),
/**
@@ -92,7 +95,8 @@ sealed interface ChatRoomDetailUIState {
*
* 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. Empty in a
+ * 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.
*/
@@ -103,18 +107,21 @@ sealed interface ChatRoomDetailUIState {
*
* 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. Empty in a group that has accepted nothing, which is
- * every group until a quorum signs an entry.
+ * 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 a profile for the group.
+ * 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: writing the group's profile is a signature by the group, and
- * `FrostSigningManager.proposeSigningBatch` throws for a device holding no
- * share of the key. It is not about permission -- any member with a share
- * may propose -- and the quorum is what decides.
+ * 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.
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
new file mode 100644
index 00000000..88622196
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupCuratedEntriesUIState.kt
@@ -0,0 +1,26 @@
+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
new file mode 100644
index 00000000..079c8c33
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupCuratedSchemasUIState.kt
@@ -0,0 +1,31 @@
+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
new file mode 100644
index 00000000..8facb25f
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupNostrProfileUIState.kt
@@ -0,0 +1,35 @@
+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
new file mode 100644
index 00000000..e3d4358b
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupPostsUIState.kt
@@ -0,0 +1,40 @@
+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
new file mode 100644
index 00000000..48cd1902
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupRelaysUIState.kt
@@ -0,0 +1,31 @@
+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/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupCuratedEntriesScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupCuratedEntriesScreenJvmTest.kt
new file mode 100644
index 00000000..dc50e0b2
--- /dev/null
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupCuratedEntriesScreenJvmTest.kt
@@ -0,0 +1,133 @@
+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
new file mode 100644
index 00000000..2bb92775
--- /dev/null
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupCuratedSchemasScreenJvmTest.kt
@@ -0,0 +1,231 @@
+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
new file mode 100644
index 00000000..8f94dd16
--- /dev/null
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileScreenJvmTest.kt
@@ -0,0 +1,153 @@
+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/GroupNostrProfileSectionJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt
deleted file mode 100644
index 79f58c1b..00000000
--- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt
+++ /dev/null
@@ -1,678 +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.ExperimentalTestApi
-import androidx.compose.ui.test.ComposeUiTest
-import androidx.compose.ui.test.assertIsDisplayed
-import androidx.compose.ui.test.getBoundsInRoot
-import androidx.compose.ui.test.assertCountEquals
-import androidx.compose.ui.test.onAllNodesWithText
-import androidx.compose.ui.test.onNodeWithContentDescription
-import androidx.compose.ui.test.onNodeWithText
-import androidx.compose.ui.test.runDesktopComposeUiTest
-import press.mantra.compose.database.model.ChatRoom
-import press.mantra.compose.database.model.GroupKeyState
-import press.mantra.compose.database.model.GroupSignedEvent
-import press.mantra.compose.database.model.intermdiate.LocalChatRoom
-import press.mantra.compose.nostr.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 com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
-import press.mantra.compose.repository.ChatRepository
-import press.mantra.compose.repository.MantraRepository
-import press.mantra.compose.repository.NostrRepository
-import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost
-import press.mantra.compose.ui.theme.MantraTheme
-import press.mantra.compose.ui.view.state.ChatRoomDetailUIState
-import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
-import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
-import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
-import kotlin.test.Test
-import kotlin.test.assertTrue
-import kotlin.time.Instant
-
-/**
- * Where the group's nostr profile sits, and when it is there at all.
- *
- * The section's contents need no test -- a name and a line of prose in a
- * `ListItem` -- but two things about it do, and neither is settled by reading the
- * screen top to bottom.
- *
- * **Its place in the order.** It goes under what the room *is* -- the key it signs
- * as and whose child it is -- and above what the room has *made*, because a
- * profile is part of the identity rather than part of the work. That ordering is
- * an intention that a later `item {}` inserted in the wrong place would silently
- * undo, and the screen is long enough that nobody would notice.
- *
- * **Its absence in a NIP-17 room.** A NIP-17 room's id is a conversation, not a
- * key it can sign as, so it has no nostr identity and never will. An empty section
- * there would promise something that is not coming, which is worse than saying
- * nothing.
- */
-@OptIn(ExperimentalTestApi::class)
-class GroupNostrProfileSectionJvmTest {
-
- private val chatRoomId = "d4c3b2a1".repeat(8)
-
- private val profile = GroupNostrProfile(
- signedEvent = GroupSignedEvent(
- id = "e".repeat(64),
- chatRoomId = chatRoomId,
- publicKey = chatRoomId,
- kind = MetadataEvent.KIND,
- tags = emptyArray(),
- content = "",
- signature = "f".repeat(128),
- createdAt = Instant.fromEpochSeconds(1_700_000_000)
- ),
- // Built rather than read out of the event: the reading checks a real
- // signature, and this test is about the screen rather than about that.
- metadata = UserMetadata().apply {
- displayName = "Translation collective"
- about = "A group translating hard books into Sesotho."
- nip05 = "group@example.com"
- }
- )
-
- @Test
- fun `the profile sits under the signing key and above the library`() = render(
- state(groupNostrProfile = profile)
- ) {
- val signingKey = onNodeWithText("Signing key").getBoundsInRoot().top
- val nostrProfile = onNodeWithText("Nostr profile").getBoundsInRoot().top
- val library = onNodeWithText("Library").getBoundsInRoot().top
-
- assertTrue(
- signingKey < nostrProfile,
- "the nostr profile section climbed above the signing key row"
- )
- assertTrue(
- nostrProfile < library,
- "the nostr profile section fell below the library"
- )
-
- onNodeWithText("Translation collective").assertIsDisplayed()
- onNodeWithText("group@example.com").assertIsDisplayed()
- }
-
- @Test
- fun `the relay lists sit under the profile and above the library`() = render(
- state(groupNostrProfile = profile, relayLists = signedRelayLists())
- ) {
- val nostrProfile = onNodeWithText("Nostr profile").getBoundsInRoot().top
- val relays = onNodeWithText("Relays").getBoundsInRoot().top
- val library = onNodeWithText("Library").getBoundsInRoot().top
-
- assertTrue(nostrProfile < relays, "the relay lists climbed above the profile")
- assertTrue(relays < library, "the relay lists fell below the library")
-
- // Every set gets a row whatever the group has said, and the two absences
- // read differently: one has never been agreed, the other was agreed empty.
- onNodeWithText("General").assertIsDisplayed()
- onNodeWithText("relay.one.example").assertIsDisplayed()
- onNodeWithText("Messages").assertIsDisplayed()
- onNodeWithText("Search").assertIsDisplayed()
- onNodeWithText("Blocked").assertIsDisplayed()
- // Two of them, because Messages and Search are both unagreed -- and the
- // count is the assertion: a row per set, not a row per set the group has
- // got round to.
- onAllNodesWithText("Not set yet").assertCountEquals(2)
- onNodeWithText("No relays in this list").assertIsDisplayed()
- onNodeWithText("Edit relays").assertIsDisplayed()
- }
-
- @Test
- fun `a member holding no share of the key reads the relays and is offered no edit`() = render(
- state(
- groupNostrProfile = profile,
- relayLists = signedRelayLists(),
- canEditNostrProfile = false
- )
- ) {
- onNodeWithText("General").assertIsDisplayed()
- onNodeWithText("relay.one.example").assertIsDisplayed()
- onNodeWithText("Edit relays").assertDoesNotExist()
- }
-
- @Test
- fun `a member holding no share of the key reads the posts and is offered no composer`() =
- render(
- state(
- groupNostrProfile = profile,
- posts = listOf(post("Something the group said", 1_700_000_000)),
- canEditNostrProfile = 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()
- }
-
- @Test
- fun `the posts sit under the relay lists and above the library`() = render(
- state(
- groupNostrProfile = profile,
- relayLists = signedRelayLists(),
- posts = listOf(post("The second thing", 1_700_000_900), post("The first thing", 1_700_000_000))
- )
- ) {
- val relays = onNodeWithText("Relays").getBoundsInRoot().top
- val posts = onNodeWithText("Posts").getBoundsInRoot().top
- val library = onNodeWithText("Library").getBoundsInRoot().top
-
- assertTrue(relays < posts, "the posts climbed above the relay lists")
- assertTrue(posts < library, "the posts fell below the library")
-
- // Newest first, and whole rather than previewed -- a post is the one thing
- // on this screen 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.
- onAllNodesWithText("Translation collective").assertCountEquals(3)
- onNodeWithText("Add post").assertIsDisplayed()
- }
-
- @Test
- fun `a group that has posted nothing says so, and offers to post`() = render(
- state(groupNostrProfile = profile, relayLists = signedRelayLists())
- ) {
- onNodeWithText("Posts").assertIsDisplayed()
- onNodeWithText("This group hasn't posted anything yet.").assertIsDisplayed()
- onNodeWithText("Add post").assertIsDisplayed()
- }
-
- @Test
- fun `the curated schemas sit under the posts and above the group's work`() = 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_900),
- schema("books", "Books worth translating", 1_700_000_000)
- )
- )
- ) {
- val posts = onNodeWithText("Posts").getBoundsInRoot().top
- val schemas = onNodeWithText("Curated schemas").getBoundsInRoot().top
- val subgroups = onNodeWithText("Subgroups").getBoundsInRoot().top
-
- assertTrue(posts < schemas, "the curated schemas climbed above the posts")
- assertTrue(schemas < subgroups, "the curated schemas fell below the group's work")
-
- // Most recently signed first, one card per list.
- 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 `a group that curates nothing says so, and offers to add a schema`() = render(
- state(groupNostrProfile = profile, relayLists = signedRelayLists())
- ) {
- onNodeWithText("Curated schemas").assertIsDisplayed()
- onNodeWithText("This group hasn't published a curated schema yet.", substring = true)
- .assertIsDisplayed()
- onNodeWithText("Add schema").assertIsDisplayed()
- }
-
- @Test
- fun `a member holding no share of the key reads the schemas and is offered no editor`() =
- render(
- state(
- groupNostrProfile = profile,
- schemas = listOf(schema("films", "Films worth translating", 1_700_000_000)),
- canEditNostrProfile = false
- )
- ) {
- // 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 in the block the group did not write,
- // and reading it takes no share of the key.
- onNodeWithText("View suggestions").assertIsDisplayed()
- }
-
- @Test
- fun `every schema offers its own queue, under its own card`() = render(
- state(
- groupNostrProfile = profile,
- schemas = listOf(
- schema("films", "Films worth translating", 1_700_000_900),
- schema("books", "Books worth translating", 1_700_000_000)
- )
- )
- ) {
- // One per list, because each list has its own queue -- and under the
- // card it belongs to rather than once for the section, since a button
- // between two cards would say nothing about which it opened.
- val buttons = onAllNodesWithText("View suggestions")
- buttons.assertCountEquals(2)
-
- val films = onNodeWithText("Films worth translating").getBoundsInRoot()
- val books = onNodeWithText("Books worth translating").getBoundsInRoot()
- val first = buttons[0].getBoundsInRoot()
- val second = buttons[1].getBoundsInRoot()
-
- assertTrue(films.top < first.top && first.top < books.top, "the first queue button is not under its card")
- assertTrue(books.top < second.top, "the second queue button is not under its card")
- }
-
- @Test
- fun `the curated entries sit under the schemas and above the group's work`() = render(
- state(
- groupNostrProfile = profile,
- schemas = listOf(schema("films", "Films worth translating", 1_700_000_000)),
- entries = listOf(
- entry("The Rise and Rise of Bitcoin", 1_700_000_900),
- entry("Magic Money", 1_700_000_300, suggestedBy = null)
- )
- )
- ) {
- val schemas = onNodeWithText("Curated schemas").getBoundsInRoot().top
- val entries = onNodeWithText("Curated entries").getBoundsInRoot().top
- val subgroups = onNodeWithText("Subgroups").getBoundsInRoot().top
-
- assertTrue(schemas < entries, "the curated entries climbed above the schemas")
- assertTrue(entries < subgroups, "the curated entries fell below the group's work")
-
- // Most recently signed first, one card per entry.
- val newest = onNodeWithText("The Rise and Rise of Bitcoin").getBoundsInRoot().top
- val oldest = onNodeWithText("Magic Money").getBoundsInRoot().top
- assertTrue(newest < oldest, "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 in the block: the profile, the
- // schema, and the two entries.
- onAllNodesWithText("Signed by the group on", substring = true).assertCountEquals(4)
- }
-
- @Test
- fun `a group that has accepted nothing says so, and offers no way to add one here`() = render(
- state(
- groupNostrProfile = profile,
- schemas = listOf(schema("films", "Films worth translating", 1_700_000_000))
- )
- ) {
- onNodeWithText("Curated entries").assertIsDisplayed()
- onNodeWithText("This group hasn't accepted anything into a list yet.", substring = true)
- .assertIsDisplayed()
-
- // An entry is accepted from the queue on the schema's card, not from
- // this section, so the section has no button of its own.
- onNodeWithText("View suggestions").assertIsDisplayed()
- onAllNodesWithText("Add entry").assertCountEquals(0)
- }
-
- @Test
- fun `every accepted entry offers a broadcast under its own card, share or no share`() = render(
- state(
- groupNostrProfile = profile,
- schemas = listOf(schema("films", "Films worth translating", 1_700_000_000)),
- entries = listOf(entry("The Rise and Rise of Bitcoin", 1_700_000_900)),
- canEditNostrProfile = false
- )
- ) {
- // The profile's, the schema's and the entry's: the entry is the one
- // event in the block whose broadcast the queue screen depends on, since
- // that is where the "Curated" mark is read back from.
- val buttons = onAllNodesWithText("Broadcast")
- buttons.assertCountEquals(3)
-
- val schemaCard = onNodeWithText("Films worth translating").getBoundsInRoot().top
- val entryCard = onNodeWithText("The Rise and Rise of Bitcoin").getBoundsInRoot().top
- val last = buttons[2].getBoundsInRoot().top
-
- assertTrue(schemaCard < entryCard, "the entry climbed above its schema")
- assertTrue(entryCard < last, "the entry's broadcast is not under its card")
- }
-
- @Test
- fun `every signed event in the block offers a broadcast, under its own card`() = 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_900))
- )
- ) {
- // One per signed event: the profile, the two relay lists the group has
- // agreed -- the withdrawn one included, since a withdrawal is a statement
- // relays still holding the old list need to hear -- the post and the
- // schema. Not the two lists the group has never agreed, which are not
- // events.
- val buttons = onAllNodesWithText("Broadcast")
- buttons.assertCountEquals(5)
-
- // Under the card it sends, in the order the block lays them out. The
- // profile card is found by its address, since its name is also the
- // post's byline.
- val profileCard = onNodeWithText("group@example.com").getBoundsInRoot().top
- val relays = onNodeWithText("Relays").getBoundsInRoot().top
- val general = onNodeWithText("General").getBoundsInRoot().top
- val blocked = onNodeWithText("Blocked").getBoundsInRoot().top
- val postCard = onNodeWithText("Something the group said").getBoundsInRoot().top
- val schemaCard = onNodeWithText("Films worth translating").getBoundsInRoot().top
- val tops = (0 until 5).map { buttons[it].getBoundsInRoot().top }
-
- assertTrue(profileCard < tops[0] && tops[0] < relays, "the profile's broadcast is not under its card")
- assertTrue(general < tops[1] && tops[1] < blocked, "the general list's broadcast is not under its row")
- assertTrue(blocked < tops[2] && tops[2] < postCard, "the blocked list's broadcast is not under its row")
- assertTrue(postCard < tops[3] && tops[3] < schemaCard, "the post's broadcast is not under its card")
- assertTrue(schemaCard < tops[4], "the schema's broadcast is not under its card")
- }
-
- @Test
- fun `a member holding no share of the key may still broadcast what the group signed`() = render(
- state(
- groupNostrProfile = profile,
- relayLists = signedRelayLists(),
- posts = listOf(post("Something the group said", 1_700_000_000)),
- canEditNostrProfile = false
- )
- ) {
- // Sending what is already signed takes no share of the key -- the
- // signature is the group's whoever repeats it -- so this is the one
- // action in the block, with the queue, that the gate does not cover.
- onAllNodesWithText("Broadcast").assertCountEquals(4)
- onNodeWithText("Edit Nostr profile").assertDoesNotExist()
- }
-
- @Test
- fun `a member holding a share of the key may propose any event the block can show`() = render(
- state(groupNostrProfile = profile, relayLists = signedRelayLists())
- ) {
- // Last in the identity block: after the schemas and before the divider,
- // because it is about the whole block rather than any one section of it.
- val schemas = onNodeWithText("Curated schemas").getBoundsInRoot().top
- val propose = onNodeWithText("Propose event").getBoundsInRoot().top
- val subgroups = onNodeWithText("Subgroups").getBoundsInRoot().top
-
- assertTrue(schemas < propose, "the paste entry climbed above the schemas")
- 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 button in the block: proposing is a signature by
- // the group, and `proposeSigning` throws for a device holding no share.
- onNodeWithText("Propose event").assertDoesNotExist()
- }
-
- @Test
- fun `a group that has said nothing says so, and offers to say something`() = render(
- state(groupNostrProfile = null)
- ) {
- onNodeWithText("Nostr profile").assertIsDisplayed()
- onNodeWithText("This group hasn't said anything about itself on Nostr yet.")
- .assertIsDisplayed()
- onNodeWithText("Edit Nostr profile").assertIsDisplayed()
- }
-
- @Test
- fun `a member holding no share of the key reads the profile and is offered no edit`() = render(
- state(groupNostrProfile = profile, canEditNostrProfile = false)
- ) {
- // The profile is worth reading whoever is looking; proposing one needs a
- // share, and `proposeSigningBatch` throws without one.
- onNodeWithText("Translation collective").assertIsDisplayed()
- onNodeWithText("Edit Nostr profile").assertDoesNotExist()
- }
-
- @Test
- fun `a NIP-17 room has no nostr identity and so no section`() = render(
- state(groupNostrProfile = null, mlsGroupState = null, canEditNostrProfile = false)
- ) {
- onNodeWithText("Nostr profile").assertDoesNotExist()
- onNodeWithText("This group hasn't said anything about itself on Nostr yet.")
- .assertDoesNotExist()
- onNodeWithText("Edit Nostr profile").assertDoesNotExist()
-
- // The relay lists, the posts and the schemas go with it: all four
- // describe a nostr identity this room does not have.
- onNodeWithText("Relays").assertDoesNotExist()
- onNodeWithText("Edit relays").assertDoesNotExist()
- onNodeWithText("Posts").assertDoesNotExist()
- onNodeWithText("Add post").assertDoesNotExist()
- onNodeWithText("Curated schemas").assertDoesNotExist()
- onNodeWithText("Add schema").assertDoesNotExist()
- onNodeWithText("Propose event").assertDoesNotExist()
- onAllNodesWithText("Broadcast").assertCountEquals(0)
-
- // The rest of the screen is untouched, so the section's absence is an
- // absence rather than a screen that failed to draw.
- onNodeWithText("Library").assertIsDisplayed()
- }
-
- /**
- * Two agreed sets and two absences, which is the only fixture that exercises
- * the three things a row can say: a list, "not set yet", and "no relays".
- */
- private fun signedRelayLists(): List = GroupRelaySet.entries.map { set ->
- when (set) {
- GroupRelaySet.General -> GroupRelayList(
- set = set,
- signedEvent = relaySignedEvent(set),
- relays = listOf(GroupRelay(RelayUrlNormalizer.normalize("wss://relay.one.example")))
- )
- // Agreed and empty: the group withdrew its list rather than never
- // having had one.
- GroupRelaySet.Blocked -> GroupRelayList(
- set = set,
- signedEvent = relaySignedEvent(set),
- relays = emptyList()
- )
- else -> GroupRelayList(set = set, signedEvent = null, relays = emptyList())
- }
- }
-
- private fun relaySignedEvent(set: GroupRelaySet) = GroupSignedEvent(
- id = "a" + set.kind.toString().padStart(5, '0') + "0".repeat(58),
- chatRoomId = chatRoomId,
- publicKey = chatRoomId,
- kind = set.kind,
- tags = emptyArray(),
- content = "",
- signature = "f".repeat(128),
- createdAt = Instant.fromEpochSeconds(1_700_000_000)
- )
-
- private fun 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, built rather than read out of a signed event:
- * the reading checks a real signature, and this test is about the screen.
- */
- private 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, built rather than read
- * out of a signed event, for the reason [schema] is. The list's schema has a
- * year, so the card has a glance to give.
- */
- private fun entry(
- title: String,
- createdAt: Long,
- suggestedBy: String? = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860"
- ): 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)
- }
- )
- )
- }
-
- 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 = LocalChatRoom(
- chatRoom = ChatRoom(
- id = chatRoomId,
- userPublicKey = "a".repeat(64),
- // Deliberately not the profile's name. The room's subject is this
- // device's name for it and the profile is the group's own, and a
- // test that used one string for both could not tell them apart.
- subject = "Translation room",
- description = "A group translating hard books.",
- initialGiftWrapPayloadId = "sdfaer",
- mlsGroupState = mlsGroupState
- )
- ),
- // A key state, so the signing key row the section has to sit under is on
- // the screen to be measured against. Not a real key: nothing here derives.
- groupKeyState = GroupKeyState(
- chatRoomId = chatRoomId,
- dkgSessionId = "c".repeat(64),
- thresholdPublicKey = "02".padEnd(66, 'a'),
- derivationPath = "m/9420/0/0",
- announcedBy = chatRoomId,
- announcedAt = Instant.fromEpochSeconds(1_700_000_000)
- ),
- groupNostrProfile = groupNostrProfile,
- groupRelayLists = relayLists,
- 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,
- assertions: ComposeUiTest.() -> Unit
- ) = runDesktopComposeUiTest(width = 400, height = 4000) {
- setContent {
- MantraTheme {
- ProvideSnackbarHost {
- Box(modifier = Modifier.fillMaxSize()) {
- ChatRoomDetailScreen(
- activeUserPublicKey = "a".repeat(64),
- chatRoomId = chatRoomId,
- relayHint = null,
- initialChatRoomDetailUIState = uiState,
- nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
- chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
- mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY,
- onNavigateToRoute = {},
- onPopBackToRoute = {},
- 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
new file mode 100644
index 00000000..15a1af7a
--- /dev/null
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupPostsScreenJvmTest.kt
@@ -0,0 +1,167 @@
+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
new file mode 100644
index 00000000..90b42888
--- /dev/null
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupRelaysScreenJvmTest.kt
@@ -0,0 +1,139 @@
+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
new file mode 100644
index 00000000..fae7c63a
--- /dev/null
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupSignedWorkFixtures.kt
@@ -0,0 +1,212 @@
+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
new file mode 100644
index 00000000..76368e53
--- /dev/null
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupSignedWorkRowsJvmTest.kt
@@ -0,0 +1,282 @@
+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()
+ }
+}