feat(groups): a row per kind of signed work on the group's screen, each opening its own

The nostr profile, the relay lists, the posts, the curated schemas and the
curated entries were five sections of cards on the group's screen, every card
with a button under it, which made the screen as long as everything the group
had ever said, with its members and its subgroups somewhere underneath. Each
section is now a row -- a card with an icon and a chevron, the shape of the
signing key row above them -- carrying the one line a member wants at a
glance: the group's name and address, how many of the four lists it has
agreed, and a count of posts, schemas and entries. The row opens a pushed
screen holding what the section held, unchanged: the cards, the broadcast
under each signed event, the queue under each schema, and the gated ways into
the editors, which sit inside the empty state where there is nothing yet. The
paste stays on the group's screen at the end of the block, since it is about
the block rather than any one row, and the whole block is still absent in a
NIP-17 room for the reason it always was.

Five routes, screens, view models and states, each reading only what its
screen shows plus whether this device can sign. The broadcast button becomes a
widget, since five screens draw it, and inComparableGroups moves to the key
extensions beside shortened, with the doc comment that had been orphaned from
it. The section tests become a fixture object and six tests: one for the rows
-- their order, what each says, which route each opens, their absence in a
NIP-17 room -- and one per screen for what the sections' tests used to check.

Replayed onto Mantra by docs/curated-to-mantra.md: GroupNostrProfileSectionJvmTest.kt: deleted, as this commit deletes it upstream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@55664cc7d4
This commit is contained in:
Kgothatso Ngako
2026-09-12 20:08:44 +02:00
parent b5cd41cb3d
commit c5c89c8d52
34 changed files with 4068 additions and 1499 deletions

View File

@@ -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.
*

View File

@@ -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 = {},
)
}
}
}

View File

@@ -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 = {},
)
}
}
}

View File

@@ -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 = {},
)
}
}
}

View File

@@ -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 = {},
)
}
}
}

View File

@@ -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<GroupRelayList>,
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 = {},
)
}
}
}

View File

@@ -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<GroupNostrProfileRoute> { backStackEntry ->
val route = backStackEntry.toRoute<GroupNostrProfileRoute>()
GroupNostrProfileScreen(
activeUserPublicKey = route.activeUserPublicKey,
chatRoomId = route.chatRoomId,
relayHint = route.relayHint,
chatRepository = databaseChatRepository,
onNavigateToRoute = { actionRoute ->
navController.navigate(route = actionRoute)
},
onNavigateBack = {
navController.popBackStack()
}
)
}
composable<GroupRelaysRoute> { backStackEntry ->
val route = backStackEntry.toRoute<GroupRelaysRoute>()
GroupRelaysScreen(
activeUserPublicKey = route.activeUserPublicKey,
chatRoomId = route.chatRoomId,
relayHint = route.relayHint,
chatRepository = databaseChatRepository,
onNavigateToRoute = { actionRoute ->
navController.navigate(route = actionRoute)
},
onNavigateBack = {
navController.popBackStack()
}
)
}
composable<GroupPostsRoute> { backStackEntry ->
val route = backStackEntry.toRoute<GroupPostsRoute>()
GroupPostsScreen(
activeUserPublicKey = route.activeUserPublicKey,
chatRoomId = route.chatRoomId,
relayHint = route.relayHint,
chatRepository = databaseChatRepository,
onNavigateToRoute = { actionRoute ->
navController.navigate(route = actionRoute)
},
onNavigateBack = {
navController.popBackStack()
}
)
}
composable<GroupCuratedSchemasRoute> { backStackEntry ->
val route = backStackEntry.toRoute<GroupCuratedSchemasRoute>()
GroupCuratedSchemasScreen(
activeUserPublicKey = route.activeUserPublicKey,
chatRoomId = route.chatRoomId,
relayHint = route.relayHint,
chatRepository = databaseChatRepository,
onNavigateToRoute = { actionRoute ->
navController.navigate(route = actionRoute)
},
onNavigateBack = {
navController.popBackStack()
}
)
}
composable<GroupCuratedEntriesRoute> { backStackEntry ->
val route = backStackEntry.toRoute<GroupCuratedEntriesRoute>()
GroupCuratedEntriesScreen(
activeUserPublicKey = route.activeUserPublicKey,
chatRoomId = route.chatRoomId,
relayHint = route.relayHint,
chatRepository = databaseChatRepository,
onNavigateToRoute = { actionRoute ->
navController.navigate(route = actionRoute)
},
onNavigateBack = {
navController.popBackStack()
}
)
}
composable<EditGroupNostrProfileRoute> { backStackEntry ->
val route = backStackEntry.toRoute<EditGroupNostrProfileRoute>()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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))
}
}

View File

@@ -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,
)
}
}
}
}

View File

@@ -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,
)
}
}
}
}

View File

@@ -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,
)
}
}
}
}

View File

@@ -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,
)
}
}
}
}

View File

@@ -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,
)
}
}
}
}

View File

@@ -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<GroupPost> = 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<GroupCuratedEntry> = 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.

View File

@@ -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<GroupCuratedEntry> = emptyList(),
): GroupCuratedEntriesUIState
data class Error(
val message: String
): GroupCuratedEntriesUIState
data object Loading: GroupCuratedEntriesUIState
}

View File

@@ -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<GroupCuratedSchema> = 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
}

View File

@@ -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
}

View File

@@ -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<GroupPost> = 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
}

View File

@@ -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<GroupRelayList> = 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
}