From c5c89c8d52f27bf10b4c50b4f528182bbfb113b5 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 20:08:44 +0200 Subject: [PATCH] feat(groups): a row per kind of signed work on the group's screen, each opening its own The nostr profile, the relay lists, the posts, the curated schemas and the curated entries were five sections of cards on the group's screen, every card with a button under it, which made the screen as long as everything the group had ever said, with its members and its subgroups somewhere underneath. Each section is now a row -- a card with an icon and a chevron, the shape of the signing key row above them -- carrying the one line a member wants at a glance: the group's name and address, how many of the four lists it has agreed, and a count of posts, schemas and entries. The row opens a pushed screen holding what the section held, unchanged: the cards, the broadcast under each signed event, the queue under each schema, and the gated ways into the editors, which sit inside the empty state where there is nothing yet. The paste stays on the group's screen at the end of the block, since it is about the block rather than any one row, and the whole block is still absent in a NIP-17 room for the reason it always was. Five routes, screens, view models and states, each reading only what its screen shows plus whether this device can sign. The broadcast button becomes a widget, since five screens draw it, and inComparableGroups moves to the key extensions beside shortened, with the doc comment that had been orphaned from it. The section tests become a fixture object and six tests: one for the rows -- their order, what each says, which route each opens, their absence in a NIP-17 room -- and one per screen for what the sections' tests used to check. Replayed onto Mantra by docs/curated-to-mantra.md: GroupNostrProfileSectionJvmTest.kt: deleted, as this commit deletes it upstream. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@55664cc7d44b163e2cf11083790d91c695b7c782 --- .../composeResources/values/strings.xml | 16 + .../press/mantra/compose/extensions/Member.kt | 17 + .../ui/composable/ChatRoomDetailScreen.kt | 961 +++--------------- .../composable/GroupCuratedEntriesScreen.kt | 365 +++++++ .../composable/GroupCuratedSchemasScreen.kt | 442 ++++++++ .../ui/composable/GroupNostrProfileScreen.kt | 353 +++++++ .../compose/ui/composable/GroupPostsScreen.kt | 352 +++++++ .../ui/composable/GroupRelaysScreen.kt | 327 ++++++ .../ui/composable/navigation/MantraNavHost.kt | 90 ++ .../routes/GroupCuratedEntriesRoute.kt | 16 + .../routes/GroupCuratedSchemasRoute.kt | 16 + .../routes/GroupNostrProfileRoute.kt | 19 + .../navigation/routes/GroupPostsRoute.kt | 15 + .../navigation/routes/GroupRelaysRoute.kt | 15 + .../widgets/buttons/BroadcastButton.kt | 38 + .../model/GroupCuratedEntriesViewModel.kt | 65 ++ .../model/GroupCuratedSchemasViewModel.kt | 66 ++ .../view/model/GroupNostrProfileViewModel.kt | 71 ++ .../ui/view/model/GroupPostsViewModel.kt | 64 ++ .../ui/view/model/GroupRelaysViewModel.kt | 62 ++ .../ui/view/state/ChatRoomDetailUIState.kt | 39 +- .../view/state/GroupCuratedEntriesUIState.kt | 26 + .../view/state/GroupCuratedSchemasUIState.kt | 31 + .../ui/view/state/GroupNostrProfileUIState.kt | 35 + .../ui/view/state/GroupPostsUIState.kt | 40 + .../ui/view/state/GroupRelaysUIState.kt | 31 + .../GroupCuratedEntriesScreenJvmTest.kt | 133 +++ .../GroupCuratedSchemasScreenJvmTest.kt | 231 +++++ .../GroupNostrProfileScreenJvmTest.kt | 153 +++ .../GroupNostrProfileSectionJvmTest.kt | 678 ------------ .../ui/composable/GroupPostsScreenJvmTest.kt | 167 +++ .../ui/composable/GroupRelaysScreenJvmTest.kt | 139 +++ .../ui/composable/GroupSignedWorkFixtures.kt | 212 ++++ .../composable/GroupSignedWorkRowsJvmTest.kt | 282 +++++ 34 files changed, 4068 insertions(+), 1499 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupCuratedEntriesScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupCuratedSchemasScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupPostsScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupRelaysScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupCuratedEntriesRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupCuratedSchemasRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupNostrProfileRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupPostsRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupRelaysRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/buttons/BroadcastButton.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupCuratedEntriesViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupCuratedSchemasViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupNostrProfileViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupPostsViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupRelaysViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupCuratedEntriesUIState.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupCuratedSchemasUIState.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupNostrProfileUIState.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupPostsUIState.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupRelaysUIState.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupCuratedEntriesScreenJvmTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupCuratedSchemasScreenJvmTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileScreenJvmTest.kt delete mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupPostsScreenJvmTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupRelaysScreenJvmTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupSignedWorkFixtures.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupSignedWorkRowsJvmTest.kt 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() + } +}