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:
@@ -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.
|
||||
*
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>()
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user