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() + } +}