feat(groups): a broadcast button under each signed event, and the relays it goes to
Until now nothing a group signed reached a relay. `FrostSigningManager.complete` files the batch as `GroupSignedEvent` rows and applies it locally, and the profile, the relay lists, the posts and the schemas are all read off those rows -- the docs on each said so, and the only way out was the copy button on the key-state sheet. This is the way out: a "Broadcast" button under every signed event in the identity block, opening `BroadcastGroupSignedEventScreen`, where the member sees and edits the relays it will go to, presses once, and reads what each relay answered. The raw JSON is at the bottom for anyone who would rather use another tool. **The send goes to the relay pool directly, not through the broadcast queue.** Everything the app publishes as itself goes through `BroadcastNostrEventRequest`, which is durable and retried, and it is not used here on purpose. The queue hangs off a `NostrEvent` row, and a row for the group's event is more than a queue entry: the home feed selects by kind, so the group's post would appear in it as anybody's; and the sync loop offers every local event id to the relays it reconciles with, so the event would reach relays the screen never named -- which would make "edit the relays" a fiction. `GroupSignedEvent` already explains at length why it is not a `NostrEvent`; this keeps that true. `EventPublishTransport` is the slice of `RelaysSocketManager` the screen needs, cut the way `LiveSubscriptionTransport` is and for the same reason: the manager cannot be stood up in a test. The cost is that a broadcast is only as durable as the button press. Each row says what its relay said, and a relay that did not answer is sent to again by pressing again. **Behind no gate.** Every other button in the block is hidden from a member holding no share of the key, because each proposes a signature by the group. Sending what the group has already signed takes no share: the signature is the group's whoever repeats it, and any member could paste the JSON into another client today. So the button is there for whoever is looking, the way the suggestion queue is. **Where it goes before anyone says otherwise** is `defaultRelaysFor`: the group's General list where it has agreed one -- its write relays, and nothing from the app's set beside them, since a member who edited the relays meant them -- and the app's publish set where it has not. A relay list also goes to the indexers, because a list sent only to the relays it names is circular; a curated schema also goes to the relays it names for its own replies, so a client reading suggestions there finds what they answer. Nothing goes to a relay the group has blocked. The seed happens once, so a list the member emptied stays empty. **Each relay's answer is shown in the relay's words.** The pool answers once per relay in three shapes -- an OK, a rejection wrapped in `NostrPublishException`, and any other error -- and the rows keep them apart, because "blocked: not on the allow list" is something a member can act on and a red icon is not. A relay still unanswered when the whole send runs out of time reads "No answer", which is not the same as refused. Answers are matched by host, since the pool names a relay by the URL it first opened a socket with. The relay-list rows get their button inside the shared card, under the row and at the end, because the four rows share one card and "under the event" has to mean under the row. An agreed empty list gets one too: a withdrawal is a statement, and relays still holding the old list need to hear it. The key-state event and the subgroup certificates get none -- `ChronicleManager` keeps the key state off even the members-only path, and a public relay is a bigger audience than that. `BroadcastGroupSignedEventViewModelJvmTest` pins the seed rules, the once-only seeding, the three answer shapes and the host matching, and that what goes on the wire is the seven fields the group hashed. `BroadcastGroupSignedEventScreenJvmTest` presses the button against a fake pool and finds each answer under its relay with the sum above them. `GroupNostrProfileSectionJvmTest` counts five buttons on a group with a profile, two agreed lists, a post and a schema, each under its own card, and finds them still there for a member with no share. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@6ae5a9676e
This commit is contained in:
1 parent
b127647145
commit
39b1c714b8
18 files changed
+2020
-50
No files matched your search
+9
-4
@@ -28,14 +28,19 @@ import press.mantra.compose.nostr.frost.GroupKeyStateEvent
|
||||
*
|
||||
* ### Why the whole event, and not a pointer to one
|
||||
*
|
||||
* There is nothing to point at. A group-signed event never travels on the wire
|
||||
* as itself -- the outbound pipeline re-authors rumors as their sender and would
|
||||
* strip the signature off -- so it is not a `NostrEvent`, and it is not a
|
||||
* `MarmotInnerEvent` either, since no member sent it. Every device derives it
|
||||
* There is nothing to point at. A group-signed event never travels between
|
||||
* members as itself -- the outbound pipeline re-authors rumors as their sender
|
||||
* and would strip the signature off -- so it is not a `NostrEvent`, and it is not
|
||||
* a `MarmotInnerEvent` either, since no member sent it. Every device derives it
|
||||
* from a signing session it took part in, or is handed it in a chronicle. The
|
||||
* columns are `NostrEvent`'s so that what is stored is an event rather than a
|
||||
* summary of one, which is what makes [verifies] answerable from the row alone.
|
||||
*
|
||||
* The one way one reaches a relay is a member sending it there by hand, from
|
||||
* `BroadcastGroupSignedEventScreen`, and that path serialises the row straight
|
||||
* to the pool without ever making a `NostrEvent` of it -- see
|
||||
* `BroadcastGroupSignedEventViewModel` for why a row would be the wrong shape.
|
||||
*
|
||||
* ### The derivation path
|
||||
*
|
||||
* [publicKey] is the group's threshold key walked to [derivationPath], and for a
|
||||
|
||||
+7
@@ -183,6 +183,13 @@ class DatabaseChatRepository(
|
||||
emptyList()
|
||||
}
|
||||
|
||||
override suspend fun groupSignedEvent(chatRoomId: String, id: HexKey): GroupSignedEvent? = try {
|
||||
database.groupSignedEventDao().getById(id)?.takeIf { it.chatRoomId == chatRoomId }
|
||||
} catch (e: Throwable) {
|
||||
logger.e("Error reading the signed event $id of $chatRoomId", e)
|
||||
null
|
||||
}
|
||||
|
||||
override suspend fun canSign(chatRoomId: String): Boolean = try {
|
||||
FrostSigningManager.canSign(database, chatRoomId)
|
||||
} catch (e: Throwable) {
|
||||
|
||||
+5
-3
@@ -925,9 +925,11 @@ object FrostSigningManager {
|
||||
// signature by now, so each applies the result itself rather than
|
||||
// waiting to be sent something it can already build -- the same
|
||||
// reasoning the transcript lines are written on. Nothing goes on the
|
||||
// wire: a signed event authored by the threshold key cannot travel as
|
||||
// an inner event anyway, because the outbound pipeline re-authors
|
||||
// rumors as their sender and would strip the group's signature off.
|
||||
// wire from here: a signed event authored by the threshold key cannot
|
||||
// travel as an inner event anyway, because the outbound pipeline
|
||||
// re-authors rumors as their sender and would strip the group's
|
||||
// signature off. Sending one to relays is a member's separate act,
|
||||
// from `BroadcastGroupSignedEventScreen`, off the recorded row.
|
||||
signedEvents.forEach { applySignedEvent(database, session, it, recorded) }
|
||||
|
||||
announce(
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package press.mantra.compose.network.relays
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import press.mantra.compose.database.model.NostrEvent
|
||||
import press.mantra.compose.network.dto.RelayDTO
|
||||
|
||||
/**
|
||||
* The slice of the relay layer a one-off publish needs.
|
||||
*
|
||||
* The same cut [LiveSubscriptionTransport] makes and for the same reason:
|
||||
* [RelaysSocketManager] starts collecting relay lists in its `init` and cannot be
|
||||
* stood up in a test, so a screen that hands it an event would otherwise have no
|
||||
* way to check what it does with the answers.
|
||||
*
|
||||
* Everything the app publishes as *itself* goes through the
|
||||
* `BroadcastNostrEventRequest` queue instead, which is durable and retried. This
|
||||
* exists for the one thing that must not: an event the group signed. The queue
|
||||
* hangs off a `NostrEvent` row, and a row for the group's event would put its post
|
||||
* into the home feed and offer it to every relay the sync loop reconciles with --
|
||||
* see `BroadcastGroupSignedEventViewModel` for the whole of that argument.
|
||||
*/
|
||||
interface EventPublishTransport {
|
||||
|
||||
/**
|
||||
* Sends [nostrEvent] to each of [relays], opening a socket to any it does not
|
||||
* already hold, and answers once per relay: the relay's OK, its rejection, or
|
||||
* the error that stopped it being asked.
|
||||
*
|
||||
* @see RelayPool.publishEvent
|
||||
*/
|
||||
suspend fun publishEvent(nostrEvent: NostrEvent, relays: Set<RelayDTO>): Flow<NostrPublishResult>
|
||||
}
|
||||
+2
-2
@@ -32,7 +32,7 @@ class RelaysSocketManager(
|
||||
private val nostrSocketClientFactory: press.mantra.compose.network.sockets.NostrSocketClientFactory,
|
||||
private val cachingImportRepository: press.mantra.compose.repository.CachingImportRepository,
|
||||
private val relayRepository: press.mantra.compose.repository.RelayRepository,
|
||||
) : LiveSubscriptionTransport {
|
||||
) : LiveSubscriptionTransport, EventPublishTransport {
|
||||
val logger = Logger.withTag("RelaysSocketManager")
|
||||
private val scope = CoroutineScope(Dispatchers.IO)
|
||||
private val relayPoolsMutex = Mutex()
|
||||
@@ -116,7 +116,7 @@ class RelaysSocketManager(
|
||||
}
|
||||
|
||||
@Throws(press.mantra.compose.exceptions.NostrPublishException::class)
|
||||
suspend fun publishEvent(nostrEvent: press.mantra.compose.database.model.NostrEvent, relays: Set<press.mantra.compose.network.dto.RelayDTO>): Flow<NostrPublishResult> {
|
||||
override suspend fun publishEvent(nostrEvent: press.mantra.compose.database.model.NostrEvent, relays: Set<press.mantra.compose.network.dto.RelayDTO>): Flow<NostrPublishResult> {
|
||||
logger.d("publishEvent: $nostrEvent")
|
||||
return relayPool.publishEvent(
|
||||
nostrEvent = nostrEvent,
|
||||
|
||||
@@ -25,11 +25,13 @@ import kotlin.time.Instant
|
||||
* events in that position. So the group's profile lives where the group's other
|
||||
* signed statements live, and this is the reading of it.
|
||||
*
|
||||
* The consequence worth stating: **nothing here has reached a relay.** A member's
|
||||
* kind:0 is published and is how the rest of nostr learns their name; a group's is
|
||||
* signed and kept, and every device that followed the signing session -- or was
|
||||
* handed the event afterwards -- holds it. Publishing it would mean a `NostrEvent`
|
||||
* row for an event no member authored, which is a decision this does not make.
|
||||
* The consequence worth stating: **nothing here reaches a relay by being signed.**
|
||||
* A member's kind:0 is published and is how the rest of nostr learns their name; a
|
||||
* group's is signed and kept, and every device that followed the signing session
|
||||
* -- or was handed the event afterwards -- holds it. It reaches a relay when a
|
||||
* member sends it there from `BroadcastGroupSignedEventScreen`, which serialises
|
||||
* the signed row directly rather than making a `NostrEvent` row for an event no
|
||||
* member authored -- a decision this still does not make.
|
||||
*
|
||||
* ### What a reading has to earn
|
||||
*
|
||||
|
||||
@@ -30,11 +30,12 @@ import com.vitorpamplona.quartz.nip17Dm.settings.tags.RelayTag as Nip17RelayTag
|
||||
* reasons `GroupNostrProfile` gives at length: a group-signed event is not a
|
||||
* `NostrEvent`, and only an event the room itself authored is the group speaking.
|
||||
*
|
||||
* ### Nothing here has reached a relay either
|
||||
* ### Nothing here reaches a relay by being signed
|
||||
*
|
||||
* The lists say where the group's work *should* go; sending it there is a separate
|
||||
* job this does not do. See `GroupNostrProfile`, which carries the same caveat for
|
||||
* the same reason.
|
||||
* act, taken by a member one event at a time from `BroadcastGroupSignedEventScreen`,
|
||||
* and the General list is what that screen fills its relay picker from. See
|
||||
* `GroupNostrProfile`, which carries the same caveat for the same reason.
|
||||
*
|
||||
* ### Public tags only, and not by preference
|
||||
*
|
||||
|
||||
@@ -125,6 +125,17 @@ interface ChatRepository {
|
||||
*/
|
||||
suspend fun groupCuratedSchemas(chatRoomId: String): List<GroupCuratedSchema>
|
||||
|
||||
/**
|
||||
* One event the group signed, as the group signed it, or null when this
|
||||
* device does not hold it or it was signed in some other room.
|
||||
*
|
||||
* Scoped to the room rather than looked up by id alone, because the id
|
||||
* arrives as a navigation argument and the screen it opens names a room in
|
||||
* its title: an event from one room shown under another room's name would
|
||||
* be a lie the signature check cannot catch, since both rooms' events verify.
|
||||
*/
|
||||
suspend fun groupSignedEvent(chatRoomId: String, id: HexKey): GroupSignedEvent?
|
||||
|
||||
/**
|
||||
* Whether this device holds a share of the group's key, and so could take
|
||||
* part in signing for it.
|
||||
@@ -317,6 +328,11 @@ interface ChatRepository {
|
||||
chatRoomId: String
|
||||
): List<GroupCuratedSchema> = emptyList()
|
||||
|
||||
override suspend fun groupSignedEvent(
|
||||
chatRoomId: String,
|
||||
id: HexKey
|
||||
): GroupSignedEvent? = null
|
||||
|
||||
override suspend fun canSign(chatRoomId: String): Boolean = false
|
||||
|
||||
override suspend fun refuseSubgroup(
|
||||
|
||||
+627
@@ -0,0 +1,627 @@
|
||||
package press.mantra.compose.ui.composable
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
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.imePadding
|
||||
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.foundation.text.input.TextFieldLineLimits
|
||||
import androidx.compose.foundation.text.input.clearText
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.CellTower
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Dns
|
||||
import androidx.compose.material3.BottomAppBar
|
||||
import androidx.compose.material3.BottomAppBarDefaults
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.FloatingActionButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
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.material3.contentColorFor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.semantics.disabled
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import mantra.composeapp.generated.resources.Res
|
||||
import mantra.composeapp.generated.resources.accepted_by_n_of_m_relays
|
||||
import mantra.composeapp.generated.resources.add_a_relay_to_broadcast_to
|
||||
import mantra.composeapp.generated.resources.add_relay
|
||||
import mantra.composeapp.generated.resources.broadcast
|
||||
import mantra.composeapp.generated.resources.copied_the_raw_json
|
||||
import mantra.composeapp.generated.resources.copy_raw_json
|
||||
import mantra.composeapp.generated.resources.eg_wss_relay_example_com
|
||||
import mantra.composeapp.generated.resources.no_relays_to_broadcast_to
|
||||
import mantra.composeapp.generated.resources.relay_accepted
|
||||
import mantra.composeapp.generated.resources.relay_could_not_send
|
||||
import mantra.composeapp.generated.resources.relay_no_answer
|
||||
import mantra.composeapp.generated.resources.relay_rejected
|
||||
import mantra.composeapp.generated.resources.relay_sending
|
||||
import mantra.composeapp.generated.resources.relay_url
|
||||
import mantra.composeapp.generated.resources.relays
|
||||
import mantra.composeapp.generated.resources.sends_the_event_exactly_as_the_group_signed_it
|
||||
import mantra.composeapp.generated.resources.signed_by_the_group_on
|
||||
import mantra.composeapp.generated.resources.that_is_not_a_relay_address
|
||||
import mantra.composeapp.generated.resources.that_relay_is_already_in_this_list
|
||||
import mantra.composeapp.generated.resources.the_signed_event
|
||||
import mantra.composeapp.generated.resources.where_the_group_has_said_it_lives_is_filled_in
|
||||
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.network.relays.EventPublishTransport
|
||||
import press.mantra.compose.nostr.GroupRelayList
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.text.ProposedEvent
|
||||
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.ScreenStateTransition
|
||||
import press.mantra.compose.ui.composable.widgets.rememberNotifier
|
||||
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.BroadcastGroupSignedEventViewModel
|
||||
import press.mantra.compose.ui.view.model.BroadcastGroupSignedEventViewModel.RelayOutcome
|
||||
import press.mantra.compose.ui.view.state.BroadcastGroupSignedEventUIState
|
||||
import press.mantra.compose.extensions.toFormattedTimeAndDateString
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* One event the group signed, and the relays to send it to.
|
||||
*
|
||||
* The group's screen shows what the group has signed; this is where one of those
|
||||
* leaves the device. The list starts at where the group has said it lives and the
|
||||
* member can change it, the button sends to every relay in it at once, and each
|
||||
* row then says what its relay answered -- accepted, rejected in the relay's own
|
||||
* words, or nothing. The raw JSON is at the bottom for anyone who would rather
|
||||
* hand it to some other tool.
|
||||
*
|
||||
* **The button sends rather than proposes.** Every other screen off the identity
|
||||
* block opens a signing session, because what it makes is a statement by the
|
||||
* group. This one moves a statement the group has already made, which any member
|
||||
* holding it may do: the signature is the group's, and no share of the key is
|
||||
* needed to repeat it. See `BroadcastGroupSignedEventViewModel` for why the send
|
||||
* goes to the pool directly rather than through the app's broadcast queue.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BroadcastGroupSignedEventScreen(
|
||||
activeUserPublicKey: HexKey,
|
||||
chatRoomId: String,
|
||||
relayHint: String?,
|
||||
signedEventId: HexKey,
|
||||
initialBroadcastGroupSignedEventUIState: BroadcastGroupSignedEventUIState =
|
||||
BroadcastGroupSignedEventUIState.Loading,
|
||||
chatRepository: ChatRepository,
|
||||
publishTransport: EventPublishTransport,
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
val broadcastGroupSignedEventViewModel: BroadcastGroupSignedEventViewModel = viewModel(
|
||||
factory = BroadcastGroupSignedEventViewModel.factory(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = chatRoomId,
|
||||
relayHint = relayHint,
|
||||
signedEventId = signedEventId,
|
||||
initialBroadcastGroupSignedEventUIState = initialBroadcastGroupSignedEventUIState,
|
||||
chatRepository = chatRepository,
|
||||
publishTransport = publishTransport,
|
||||
)
|
||||
)
|
||||
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
// Read out here rather than in a click handler: both are composable and an
|
||||
// onClick lambda is not.
|
||||
val notify = rememberNotifier(rememberCoroutineScope())
|
||||
val noRelays = stringResource(Res.string.add_a_relay_to_broadcast_to)
|
||||
val copied = stringResource(Res.string.copied_the_raw_json)
|
||||
|
||||
ScreenStateTransition(broadcastGroupSignedEventViewModel.broadcastGroupSignedEventUIState) { uiState ->
|
||||
when (val broadcastGroupSignedEventUIState = uiState) {
|
||||
is BroadcastGroupSignedEventUIState.Error -> {
|
||||
// Nothing to retry: the room and the event came from navigation
|
||||
// arguments, and reading them again with the same ones fails the
|
||||
// same way.
|
||||
ErrorState(message = broadcastGroupSignedEventUIState.message, onRetry = null)
|
||||
}
|
||||
|
||||
is BroadcastGroupSignedEventUIState.Loaded -> {
|
||||
val signedEvent = broadcastGroupSignedEventUIState.signedEvent
|
||||
val urlFieldState = rememberTextFieldState()
|
||||
val isPending = broadcastGroupSignedEventViewModel.isActionPending.value
|
||||
val relays = broadcastGroupSignedEventViewModel.relays
|
||||
|
||||
// The list comes from the state rather than from whatever loaded
|
||||
// it, so a screen handed a loaded state -- a preview, a layout
|
||||
// test -- fills in the same as the app does. Once only, so a list
|
||||
// the member emptied stays empty.
|
||||
LaunchedEffect(broadcastGroupSignedEventUIState) {
|
||||
broadcastGroupSignedEventViewModel.seedRelays(
|
||||
relayLists = broadcastGroupSignedEventUIState.relayLists,
|
||||
kind = signedEvent.kind,
|
||||
tags = signedEvent.tags,
|
||||
content = signedEvent.content,
|
||||
)
|
||||
}
|
||||
|
||||
// A refusal is about what is in the field, so it goes the moment
|
||||
// the field changes rather than sitting under a URL that has since
|
||||
// been corrected.
|
||||
LaunchedEffect(urlFieldState) {
|
||||
snapshotFlow { urlFieldState.text.toString() }
|
||||
.collect { broadcastGroupSignedEventViewModel.clearAddFailure() }
|
||||
}
|
||||
|
||||
// As the group signed it, byte for byte: what is copied is exactly
|
||||
// what goes on the wire, because the point of copying it is to
|
||||
// hand somebody something they can verify.
|
||||
val json = signedEvent.toEvent().toJson()
|
||||
val summary = ProposedEvent.summarize(signedEvent.toEvent())
|
||||
|
||||
// M3 gives a FAB no `enabled`, so borrow the disabled colours every
|
||||
// other button in the app uses rather than inventing a shade here.
|
||||
val buttonColors = ButtonDefaults.buttonColors()
|
||||
val canSend = !isPending
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
|
||||
modifier = Modifier.imePadding(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
broadcastGroupSignedEventUIState.localChatRoom.RenderChatRoomTitleText()
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
BottomAppBar(
|
||||
actions = {},
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(
|
||||
modifier = if (canSend) {
|
||||
Modifier
|
||||
} else {
|
||||
// Looking unavailable is not being unavailable.
|
||||
Modifier.semantics { disabled() }
|
||||
},
|
||||
containerColor = if (canSend) {
|
||||
FloatingActionButtonDefaults.containerColor
|
||||
} else {
|
||||
buttonColors.disabledContainerColor
|
||||
},
|
||||
contentColor = if (canSend) {
|
||||
contentColorFor(FloatingActionButtonDefaults.containerColor)
|
||||
} else {
|
||||
buttonColors.disabledContentColor
|
||||
},
|
||||
onClick = {
|
||||
if (!canSend) return@ExtendedFloatingActionButton
|
||||
|
||||
broadcastGroupSignedEventViewModel.broadcast(
|
||||
signedEvent = signedEvent,
|
||||
// Stays on the screen: an empty list is
|
||||
// a question rather than a mistake.
|
||||
onNoRelays = { notify(noRelays) }
|
||||
)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.CellTower,
|
||||
contentDescription = "Broadcast to these relays"
|
||||
)
|
||||
Text(stringResource(Res.string.broadcast))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
|
||||
.padding(horizontal = MaterialTheme.spacing.screenMargin),
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap)
|
||||
) {
|
||||
// What is being sent, in the words the signing screen used
|
||||
// for it: a member deciding where a post goes is deciding
|
||||
// about a post, and "kind 1" answers a question nobody asked.
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.padding(top = MaterialTheme.spacing.containerPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)
|
||||
) {
|
||||
Text(
|
||||
text = summary.label,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
Text(
|
||||
text = summary.detail,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 4
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(
|
||||
Res.string.signed_by_the_group_on,
|
||||
signedEvent.createdAt.toFormattedTimeAndDateString()
|
||||
),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(
|
||||
Res.string.sends_the_event_exactly_as_the_group_signed_it
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacing.itemGap))
|
||||
|
||||
Text(
|
||||
text = stringResource(Res.string.relays),
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(
|
||||
Res.string.where_the_group_has_said_it_lives_is_filled_in
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.weight(1f)
|
||||
.background(BottomAppBarDefaults.containerColor),
|
||||
state = urlFieldState,
|
||||
lineLimits = TextFieldLineLimits.SingleLine,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = Color.Transparent,
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
disabledBorderColor = Color.Transparent
|
||||
),
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Dns, contentDescription = Decorative)
|
||||
},
|
||||
label = { Text(stringResource(Res.string.relay_url)) },
|
||||
placeholder = {
|
||||
Text(stringResource(Res.string.eg_wss_relay_example_com))
|
||||
},
|
||||
isError = broadcastGroupSignedEventViewModel.addFailure != null
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(MaterialTheme.spacing.itemGap))
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (
|
||||
broadcastGroupSignedEventViewModel.addRelay(
|
||||
urlFieldState.text.toString()
|
||||
)
|
||||
) {
|
||||
urlFieldState.clearText()
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = stringResource(Res.string.add_relay)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Named rather than silent. An add button that does
|
||||
// nothing for a URL it refuses is indistinguishable from
|
||||
// a missed tap.
|
||||
broadcastGroupSignedEventViewModel.addFailure?.let { failure ->
|
||||
Text(
|
||||
text = when (failure) {
|
||||
BroadcastGroupSignedEventViewModel.AddFailure.NotARelay ->
|
||||
stringResource(Res.string.that_is_not_a_relay_address)
|
||||
BroadcastGroupSignedEventViewModel.AddFailure.AlreadyListed ->
|
||||
stringResource(
|
||||
Res.string.that_relay_is_already_in_this_list
|
||||
)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// The last press, summed up, above the rows that break it
|
||||
// down. Persistent rather than a snackbar, because it is
|
||||
// the answer the member came for and a snackbar would be
|
||||
// gone before the last relay had been read.
|
||||
broadcastGroupSignedEventViewModel.lastBroadcast?.let { last ->
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(
|
||||
Res.string.accepted_by_n_of_m_relays,
|
||||
last.accepted,
|
||||
last.asked
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (last.accepted == last.asked) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.error
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (relays.isEmpty()) {
|
||||
item {
|
||||
EmptyState(
|
||||
message = stringResource(Res.string.no_relays_to_broadcast_to),
|
||||
icon = Icons.Default.Dns
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(items = relays, key = { it.url }) { relay ->
|
||||
BroadcastRelayRow(
|
||||
relay = relay,
|
||||
outcome = broadcastGroupSignedEventViewModel.outcomes[relay],
|
||||
// A row cannot leave while its relay is being
|
||||
// asked: the answer would arrive for a relay
|
||||
// that is no longer there to show it.
|
||||
canRemove = !isPending,
|
||||
onRemove = {
|
||||
broadcastGroupSignedEventViewModel.removeRelay(relay)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacing.sectionGap))
|
||||
|
||||
Text(
|
||||
text = stringResource(Res.string.the_signed_event),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacing.relatedGap))
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = MaterialTheme.shapes.small
|
||||
) {
|
||||
Text(
|
||||
text = json,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(MaterialTheme.spacing.containerPadding)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacing.itemGap))
|
||||
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
clipboardManager.setText(AnnotatedString(json))
|
||||
notify(copied)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(Icons.Default.ContentCopy, contentDescription = Decorative)
|
||||
Spacer(modifier = Modifier.width(MaterialTheme.spacing.space100))
|
||||
Text(text = stringResource(Res.string.copy_raw_json))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacing.sectionGap))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BroadcastGroupSignedEventUIState.Loading -> {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.screenMargin),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap))
|
||||
|
||||
Text(
|
||||
text = stringResource(Res.string.broadcast),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
LoadingDataIndicator(fillScreen = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(true) {
|
||||
if (initialBroadcastGroupSignedEventUIState == BroadcastGroupSignedEventUIState.Loading) {
|
||||
broadcastGroupSignedEventViewModel.initiateBroadcastGroupSignedEvent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One relay to send to, and what it said the last time it was asked.
|
||||
*
|
||||
* The answer is on a second line under the host rather than in an icon, because
|
||||
* the answers that matter are the relay's own words -- "blocked: not on the
|
||||
* allow list" is something a member can act on, and a red icon is not.
|
||||
*/
|
||||
@Composable
|
||||
private fun BroadcastRelayRow(
|
||||
relay: NormalizedRelayUrl,
|
||||
outcome: RelayOutcome?,
|
||||
canRemove: Boolean,
|
||||
onRemove: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = MaterialTheme.spacing.relatedGap),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)
|
||||
) {
|
||||
Text(
|
||||
// The host rather than the whole URL: every relay here starts
|
||||
// `wss://` -- nothing else is allowed in -- so the scheme is four
|
||||
// characters of nothing in front of the part that differs.
|
||||
text = relay.displayUrl(),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
outcome?.let {
|
||||
Text(
|
||||
text = when (it) {
|
||||
RelayOutcome.Sending -> stringResource(Res.string.relay_sending)
|
||||
RelayOutcome.Accepted -> stringResource(Res.string.relay_accepted)
|
||||
is RelayOutcome.Rejected ->
|
||||
stringResource(Res.string.relay_rejected, it.reason)
|
||||
is RelayOutcome.Failed ->
|
||||
stringResource(Res.string.relay_could_not_send, it.reason)
|
||||
RelayOutcome.NoAnswer -> stringResource(Res.string.relay_no_answer)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = when (it) {
|
||||
RelayOutcome.Accepted -> MaterialTheme.colorScheme.primary
|
||||
is RelayOutcome.Rejected, is RelayOutcome.Failed ->
|
||||
MaterialTheme.colorScheme.error
|
||||
RelayOutcome.Sending, RelayOutcome.NoAnswer ->
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(onClick = onRemove, enabled = canRemove) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "Remove relay",
|
||||
// The button's own disabled colour while a send is out, rather
|
||||
// than a red that promises a tap will do something.
|
||||
tint = if (canRemove) MaterialTheme.colorScheme.error else LocalContentColor.current
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ConformancePreviews
|
||||
@Composable
|
||||
private fun BroadcastGroupSignedEventScreenPreview() {
|
||||
val chatRoomId = "d4c3b2a1".repeat(8)
|
||||
|
||||
MantraTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
BroadcastGroupSignedEventScreen(
|
||||
activeUserPublicKey = "",
|
||||
chatRoomId = chatRoomId,
|
||||
relayHint = null,
|
||||
signedEventId = "b".repeat(64),
|
||||
initialBroadcastGroupSignedEventUIState = BroadcastGroupSignedEventUIState.Loaded(
|
||||
localChatRoom = LocalChatRoom(
|
||||
chatRoom = ChatRoom(
|
||||
id = chatRoomId,
|
||||
userPublicKey = "",
|
||||
subject = "Translation room",
|
||||
description = "A group translating hard books.",
|
||||
initialGiftWrapPayloadId = "sdfaer",
|
||||
mlsGroupState = "state"
|
||||
),
|
||||
),
|
||||
signedEvent = GroupSignedEvent(
|
||||
id = "b".repeat(64),
|
||||
chatRoomId = chatRoomId,
|
||||
publicKey = chatRoomId,
|
||||
kind = TextNoteEvent.KIND,
|
||||
tags = emptyArray(),
|
||||
content = "The first chapter is out.",
|
||||
signature = "f".repeat(128),
|
||||
createdAt = Instant.fromEpochSeconds(1_700_000_000)
|
||||
),
|
||||
// Nothing agreed, so the list fills in from the app's own
|
||||
// publish set -- the state a group meets this screen in
|
||||
// before it has edited its relays.
|
||||
relayLists = GroupRelayList.allAmong(emptyList(), chatRoomId),
|
||||
),
|
||||
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
|
||||
publishTransport = object : EventPublishTransport {
|
||||
override suspend fun publishEvent(
|
||||
nostrEvent: press.mantra.compose.database.model.NostrEvent,
|
||||
relays: Set<press.mantra.compose.network.dto.RelayDTO>
|
||||
): Flow<press.mantra.compose.network.relays.NostrPublishResult> = emptyFlow()
|
||||
},
|
||||
onNavigateBack = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+135
-33
@@ -1,6 +1,9 @@
|
||||
package press.mantra.compose.ui.composable
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -25,6 +28,7 @@ import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Inbox
|
||||
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
|
||||
import androidx.compose.material.icons.filled.Campaign
|
||||
import androidx.compose.material.icons.filled.CellTower
|
||||
import androidx.compose.material.icons.filled.DataObject
|
||||
import androidx.compose.material.icons.filled.Dns
|
||||
import androidx.compose.material.icons.filled.Draw
|
||||
@@ -79,6 +83,7 @@ import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendin
|
||||
import press.mantra.compose.ui.composable.navigation.routes.Route
|
||||
import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.AddGroupPostRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.EditGroupCuratedSchemaRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.CuratedSuggestionListRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ProposeGroupEventRoute
|
||||
@@ -170,6 +175,7 @@ import mantra.composeapp.generated.resources.this_room_signs_as
|
||||
import mantra.composeapp.generated.resources.derivation_path
|
||||
import mantra.composeapp.generated.resources.key_ceremony
|
||||
import mantra.composeapp.generated.resources.agreed_on
|
||||
import mantra.composeapp.generated.resources.broadcast
|
||||
import mantra.composeapp.generated.resources.the_signed_event
|
||||
import mantra.composeapp.generated.resources.copy_the_signed_event
|
||||
import mantra.composeapp.generated.resources.copied_the_signed_event
|
||||
@@ -184,7 +190,7 @@ import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
|
||||
import press.mantra.compose.ui.theme.ConformancePreviews
|
||||
import kotlin.time.Instant
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun ChatRoomDetailScreen(
|
||||
activeUserPublicKey: HexKey,
|
||||
@@ -240,6 +246,19 @@ fun ChatRoomDetailScreen(
|
||||
) {
|
||||
var isShowingGroupKeyState by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// The one way into the broadcast screen, for every signed
|
||||
// event in the identity block below.
|
||||
val broadcast: (GroupSignedEvent) -> Unit = { signedEvent ->
|
||||
onNavigateToRoute.invoke(
|
||||
BroadcastGroupSignedEventRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = chatRoomId,
|
||||
relayHint = relayHint,
|
||||
signedEventId = signedEvent.id
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f).fillMaxWidth().padding(MaterialTheme.spacing.space250),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
@@ -381,10 +400,29 @@ fun ChatRoomDetailScreen(
|
||||
|
||||
item {
|
||||
chatRoomDetailUIState.groupNostrProfile?.let { groupNostrProfile ->
|
||||
GroupNostrProfileCard(
|
||||
groupNostrProfile = groupNostrProfile,
|
||||
publicKey = chatRoomId
|
||||
)
|
||||
// The card and its broadcast button as one
|
||||
// item, so the button stays under the event
|
||||
// it sends however the list is arranged.
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
GroupNostrProfileCard(
|
||||
groupNostrProfile = groupNostrProfile,
|
||||
publicKey = chatRoomId
|
||||
)
|
||||
|
||||
// Behind no gate, here and under every
|
||||
// other signed event in the block: sending
|
||||
// what the group has already signed takes
|
||||
// no share of the key, since the
|
||||
// signature on it is the group's whoever
|
||||
// repeats it. Only *making* a statement
|
||||
// is gated.
|
||||
BroadcastButton(
|
||||
onClick = { broadcast(groupNostrProfile.signedEvent) }
|
||||
)
|
||||
}
|
||||
} ?: Text(
|
||||
stringResource(
|
||||
Res.string.this_group_has_not_said_anything_about_itself
|
||||
@@ -439,7 +477,8 @@ fun ChatRoomDetailScreen(
|
||||
|
||||
item {
|
||||
GroupRelayListsCard(
|
||||
relayLists = chatRoomDetailUIState.groupRelayLists
|
||||
relayLists = chatRoomDetailUIState.groupRelayLists,
|
||||
onBroadcast = broadcast
|
||||
)
|
||||
}
|
||||
|
||||
@@ -498,12 +537,21 @@ fun ChatRoomDetailScreen(
|
||||
items = chatRoomDetailUIState.groupPosts,
|
||||
key = { post -> post.signedEvent.id }
|
||||
) { post ->
|
||||
GroupPostCard(
|
||||
post = post,
|
||||
publicKey = chatRoomId,
|
||||
groupNostrProfile =
|
||||
chatRoomDetailUIState.groupNostrProfile
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
GroupPostCard(
|
||||
post = post,
|
||||
publicKey = chatRoomId,
|
||||
groupNostrProfile =
|
||||
chatRoomDetailUIState.groupNostrProfile
|
||||
)
|
||||
|
||||
BroadcastButton(
|
||||
onClick = { broadcast(post.signedEvent) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -602,30 +650,48 @@ fun ChatRoomDetailScreen(
|
||||
// strangers, and reading the answers
|
||||
// takes no share of the key. Per card
|
||||
// because each list has its own queue.
|
||||
TextButton(
|
||||
onClick = {
|
||||
onNavigateToRoute.invoke(
|
||||
CuratedSuggestionListRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = chatRoomId,
|
||||
relayHint = relayHint,
|
||||
identifier = curated.identifier
|
||||
//
|
||||
// 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))
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Inbox,
|
||||
contentDescription = Decorative
|
||||
)
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier.width(
|
||||
MaterialTheme.spacing.space125
|
||||
)
|
||||
BroadcastButton(
|
||||
onClick = { broadcast(curated.signedEvent) }
|
||||
)
|
||||
|
||||
Text(stringResource(Res.string.view_suggestions))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1461,7 +1527,8 @@ internal fun GroupKeyStateSheetContent(
|
||||
* 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
|
||||
* has been published: see `GroupNostrProfile`.
|
||||
* was published by being signed -- see `GroupNostrProfile` -- and the broadcast
|
||||
* button under it is how it gets to a relay.
|
||||
*/
|
||||
@Composable
|
||||
private fun GroupNostrProfileCard(
|
||||
@@ -1537,7 +1604,10 @@ private fun GroupNostrProfileCard(
|
||||
* deliberate withdrawal behind an oversight.
|
||||
*/
|
||||
@Composable
|
||||
private fun GroupRelayListsCard(relayLists: List<GroupRelayList>) {
|
||||
private fun GroupRelayListsCard(
|
||||
relayLists: List<GroupRelayList>,
|
||||
onBroadcast: (GroupSignedEvent) -> Unit
|
||||
) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
relayLists.forEach { list ->
|
||||
ListItem(
|
||||
@@ -1563,10 +1633,42 @@ private fun GroupRelayListsCard(relayLists: List<GroupRelayList>) {
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// Under the row it sends, and only where there is something signed
|
||||
// to send: a list the group has never agreed is not an event. Inside
|
||||
// the card because the four rows share one, so "under the event" has
|
||||
// to mean under the row -- and at the end, where M3 puts a card's
|
||||
// actions. An agreed empty list gets one too: a withdrawal is a
|
||||
// statement, and relays still holding the old list need to hear it.
|
||||
list.signedEvent?.let { signedEvent ->
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.padding(horizontal = MaterialTheme.spacing.compactPadding),
|
||||
contentAlignment = Alignment.CenterEnd
|
||||
) {
|
||||
BroadcastButton(onClick = { onBroadcast(signedEvent) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The way into `BroadcastGroupSignedEventScreen`, drawn the same under every
|
||||
* signed event in the identity block so that it reads as one action wherever it
|
||||
* sits. The icon is decorative because the label is beside it.
|
||||
*/
|
||||
@Composable
|
||||
private fun BroadcastButton(onClick: () -> Unit) {
|
||||
TextButton(onClick = onClick) {
|
||||
Icon(Icons.Default.CellTower, contentDescription = Decorative)
|
||||
|
||||
Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125))
|
||||
|
||||
Text(stringResource(Res.string.broadcast))
|
||||
}
|
||||
}
|
||||
|
||||
/** The name of a relay list, for the summary on the group's screen. */
|
||||
private fun GroupRelaySet.summaryLabel() = when (this) {
|
||||
GroupRelaySet.General -> Res.string.relay_set_general
|
||||
|
||||
+21
@@ -43,6 +43,7 @@ import press.mantra.compose.ui.composable.DkgRound2ApprovalScreen
|
||||
import press.mantra.compose.ui.composable.EditGroupNostrProfileScreen
|
||||
import press.mantra.compose.ui.composable.AddGroupPostScreen
|
||||
import press.mantra.compose.ui.composable.EditGroupCuratedSchemaScreen
|
||||
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
|
||||
@@ -133,6 +134,7 @@ import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.EditGroupNostrProfileRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.AddGroupPostRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.EditGroupCuratedSchemaRoute
|
||||
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.ProposeGroupEventRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute
|
||||
@@ -1129,6 +1131,25 @@ fun MantraNavHost(
|
||||
}
|
||||
)
|
||||
}
|
||||
composable<BroadcastGroupSignedEventRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<BroadcastGroupSignedEventRoute>()
|
||||
|
||||
BroadcastGroupSignedEventScreen(
|
||||
activeUserPublicKey = route.activeUserPublicKey,
|
||||
chatRoomId = route.chatRoomId,
|
||||
relayHint = route.relayHint,
|
||||
signedEventId = route.signedEventId,
|
||||
chatRepository = databaseChatRepository,
|
||||
// The one screen handed the pool itself rather than a queue
|
||||
// to write to: a group-signed event must not become a
|
||||
// `NostrEvent` row, and the queue hangs off one. See
|
||||
// `BroadcastGroupSignedEventViewModel`.
|
||||
publishTransport = synchronizationViewModel.relaysSocketManager,
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
composable<ProposeGroupEventRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<ProposeGroupEventRoute>()
|
||||
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package press.mantra.compose.ui.composable.navigation.routes
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class BroadcastGroupSignedEventRoute(
|
||||
val activeUserPublicKey: String,
|
||||
val chatRoomId: String, // TODO: have this as a publicKey
|
||||
val relayHint: String?,
|
||||
/** The `GroupSignedEvent` to send, which is also the event id the group signed. */
|
||||
val signedEventId: String,
|
||||
): Route()
|
||||
+441
@@ -0,0 +1,441 @@
|
||||
package press.mantra.compose.ui.view.model
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
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 co.touchlab.kermit.Logger
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.flow.take
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import press.mantra.compose.database.model.GroupSignedEvent
|
||||
import press.mantra.compose.database.model.NostrEvent
|
||||
import press.mantra.compose.exceptions.NostrPublishException
|
||||
import press.mantra.compose.network.dto.RelayDTO
|
||||
import press.mantra.compose.network.relays.EventPublishTransport
|
||||
import press.mantra.compose.network.relays.NostrPublishResult
|
||||
import press.mantra.compose.network.relays.RelayPool
|
||||
import press.mantra.compose.network.sockets.NostrIncomingMessage
|
||||
import press.mantra.compose.nostr.GroupRelayList
|
||||
import press.mantra.compose.nostr.GroupRelaySet
|
||||
import press.mantra.compose.nostr.Relays
|
||||
import press.mantra.compose.nostr.curated.CuratedSchemaEvent
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.ui.view.state.BroadcastGroupSignedEventUIState
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* One event the group signed, sent to the relays a member picks.
|
||||
*
|
||||
* Signing and sending are two acts here, on purpose. `FrostSigningManager.complete`
|
||||
* files what a quorum signed and applies it locally, and nothing leaves the device;
|
||||
* this is the second act, taken by a member, one event at a time, to relays they
|
||||
* can see and change before pressing the button. What goes on the wire is the
|
||||
* event exactly as the group signed it -- the signature is the group's, so any
|
||||
* member holding the event can send it and no share of the key is needed.
|
||||
*
|
||||
* ### Why not the broadcast queue
|
||||
*
|
||||
* Everything the app publishes as itself goes through `BroadcastNostrEventRequest`,
|
||||
* which is durable, retried and drained in the background. It is not used here
|
||||
* because it hangs off a `NostrEvent` row, and a row for the group's event is
|
||||
* more than a queue entry: the home feed selects by kind, so the group's post
|
||||
* would appear in it as if it were anybody's; and the sync loop offers every
|
||||
* local event id to the relays it reconciles with, so the event would reach
|
||||
* relays this screen never named. `GroupSignedEvent` explains at length why it is
|
||||
* not a `NostrEvent`; this keeps that true. The cost is that a broadcast is only
|
||||
* as durable as the button press -- what each relay said is shown, and a relay
|
||||
* that did not answer is sent to again by pressing again.
|
||||
*
|
||||
* ### Where the relays come from
|
||||
*
|
||||
* [defaultRelaysFor]: the group's own General list where it has agreed one, the
|
||||
* app's publish set where it has not, plus the indexers for a relay list and the
|
||||
* list's own relays for a schema, minus anything the group has blocked. The
|
||||
* member can add and remove before sending, and the seeded list is a suggestion
|
||||
* rather than a rule.
|
||||
*/
|
||||
class BroadcastGroupSignedEventViewModel(
|
||||
val chatRoomId: String,
|
||||
val signedEventId: HexKey,
|
||||
val activeUserPublicKey: HexKey,
|
||||
val relayHint: String?,
|
||||
initialBroadcastGroupSignedEventUIState: BroadcastGroupSignedEventUIState,
|
||||
val chatRepository: ChatRepository,
|
||||
val publishTransport: EventPublishTransport,
|
||||
): ViewModel() {
|
||||
|
||||
var broadcastGroupSignedEventUIState: BroadcastGroupSignedEventUIState by mutableStateOf(
|
||||
initialBroadcastGroupSignedEventUIState
|
||||
)
|
||||
private set
|
||||
|
||||
private val logger = Logger.withTag(TAG)
|
||||
|
||||
val isActionPending: MutableState<Boolean> = mutableStateOf(false)
|
||||
|
||||
/**
|
||||
* The relays the event will be sent to, in the order they are shown.
|
||||
*
|
||||
* Held on the view model rather than in the composition, like the relay
|
||||
* editor's working copy, so a rotation does not empty the list.
|
||||
*/
|
||||
val relays = mutableStateListOf<NormalizedRelayUrl>()
|
||||
|
||||
/**
|
||||
* What each relay said the last time the button was pressed.
|
||||
*
|
||||
* Keyed by relay rather than kept as a list, because a relay removed after it
|
||||
* answered takes its answer with it, and one added afterwards has none until
|
||||
* the next press.
|
||||
*/
|
||||
val outcomes = mutableStateMapOf<NormalizedRelayUrl, RelayOutcome>()
|
||||
|
||||
/**
|
||||
* Why the last add did not happen, or null. Cleared by the next keystroke.
|
||||
*
|
||||
* The same two refusals as the relay editor's, for the same reason: an add
|
||||
* button that does nothing for a URL it refuses is indistinguishable from a
|
||||
* missed tap.
|
||||
*/
|
||||
var addFailure: AddFailure? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
/**
|
||||
* How the last press went, or null before the first one.
|
||||
*
|
||||
* Shown on the screen rather than flashed in a snackbar, because it is the
|
||||
* answer the member came for and it should still be there after the rows
|
||||
* under it have been read. Cleared when the next press starts.
|
||||
*/
|
||||
var lastBroadcast: BroadcastSummary? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
private var isSeeded = false
|
||||
|
||||
/** [accepted] of [asked] relays said OK. */
|
||||
data class BroadcastSummary(val accepted: Int, val asked: Int)
|
||||
|
||||
enum class AddFailure {
|
||||
/** Not a `wss://` URL, or one pointing at this machine. */
|
||||
NotARelay,
|
||||
|
||||
/** Already in the list, which is a no-op rather than a mistake. */
|
||||
AlreadyListed,
|
||||
}
|
||||
|
||||
/** One relay's answer to one press of the button. */
|
||||
sealed interface RelayOutcome {
|
||||
/** Asked, and not yet answered. */
|
||||
data object Sending : RelayOutcome
|
||||
|
||||
/** The relay's OK: it holds the event now, or already did. */
|
||||
data object Accepted : RelayOutcome
|
||||
|
||||
/** The relay answered and said no, in its own words. */
|
||||
data class Rejected(val reason: String) : RelayOutcome
|
||||
|
||||
/** The relay could not be asked: no connection, or one that dropped. */
|
||||
data class Failed(val reason: String) : RelayOutcome
|
||||
|
||||
/** Asked, and still unanswered when the whole broadcast ran out of time. */
|
||||
data object NoAnswer : RelayOutcome
|
||||
}
|
||||
|
||||
fun initiateBroadcastGroupSignedEvent() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
|
||||
val signedEvent = chatRepository.groupSignedEvent(chatRoomId, signedEventId)
|
||||
|
||||
broadcastGroupSignedEventUIState = when {
|
||||
localChatRoom == null ->
|
||||
BroadcastGroupSignedEventUIState.Error("Couldn't find the chat room")
|
||||
|
||||
signedEvent == null ->
|
||||
BroadcastGroupSignedEventUIState.Error(
|
||||
"This device doesn't hold that signed event"
|
||||
)
|
||||
|
||||
else -> BroadcastGroupSignedEventUIState.Loaded(
|
||||
localChatRoom = localChatRoom,
|
||||
signedEvent = signedEvent,
|
||||
relayLists = chatRepository.groupRelayLists(chatRoomId),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills the list from what the group has said, once.
|
||||
*
|
||||
* Called from the screen rather than from the loader, for the reason the
|
||||
* relay editor's `seedWorkingCopy` gives: a preview and a layout test hand a
|
||||
* loaded state straight in. Once and only once, so a member who emptied the
|
||||
* list on purpose does not find it refilled by a state the view model re-emits.
|
||||
*/
|
||||
fun seedRelays(relayLists: List<GroupRelayList>, kind: Kind, tags: Array<Array<String>>, content: String) {
|
||||
if (isSeeded) return
|
||||
isSeeded = true
|
||||
|
||||
relays.addAll(defaultRelaysFor(relayLists, kind, tags, content))
|
||||
}
|
||||
|
||||
fun clearAddFailure() {
|
||||
addFailure = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds [url] to the list, or says why it did not.
|
||||
*
|
||||
* True when the row appeared, which is the caller's cue to clear the field.
|
||||
*/
|
||||
fun addRelay(url: String): Boolean {
|
||||
val relayUrl = GroupRelaySet.relayUrlOrNull(url) ?: run {
|
||||
addFailure = AddFailure.NotARelay
|
||||
return false
|
||||
}
|
||||
|
||||
if (relayUrl in relays) {
|
||||
addFailure = AddFailure.AlreadyListed
|
||||
return false
|
||||
}
|
||||
|
||||
relays.add(relayUrl)
|
||||
addFailure = null
|
||||
return true
|
||||
}
|
||||
|
||||
fun removeRelay(relayUrl: NormalizedRelayUrl) {
|
||||
relays.remove(relayUrl)
|
||||
outcomes.remove(relayUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends [signedEvent] to every relay in the list and records what each said.
|
||||
*
|
||||
* One call to the pool for the whole list, which opens a socket to any relay
|
||||
* it does not hold and answers once per relay -- an OK, a rejection with the
|
||||
* relay's reason, or the error that stopped it being asked. Each answer lands
|
||||
* in [outcomes] as it arrives, so the rows fill in one by one rather than all
|
||||
* at the end; a relay that has not answered when the whole thing runs out of
|
||||
* time is marked so, which is different from having been refused.
|
||||
*
|
||||
* [lastBroadcast] sums it up once every row has settled. [onNoRelays] instead
|
||||
* of a send to nobody: an empty list is a question rather than a mistake, and
|
||||
* the pool would otherwise fall back to the *member's* relays, which is not
|
||||
* what an empty list on this screen means.
|
||||
*
|
||||
* Returns the send, for a caller that has to wait for it, and null when
|
||||
* nothing was sent.
|
||||
*/
|
||||
fun broadcast(
|
||||
signedEvent: GroupSignedEvent,
|
||||
onNoRelays: () -> Unit,
|
||||
): Job? {
|
||||
// Guard against double submits. A second press while the first is out
|
||||
// would answer each relay's OK twice and count it twice.
|
||||
if (isActionPending.value) return null
|
||||
|
||||
val targets = relays.toList()
|
||||
if (targets.isEmpty()) {
|
||||
onNoRelays.invoke()
|
||||
return null
|
||||
}
|
||||
|
||||
isActionPending.value = true
|
||||
lastBroadcast = null
|
||||
outcomes.clear()
|
||||
targets.forEach { outcomes[it] = RelayOutcome.Sending }
|
||||
|
||||
val wireEvent = wireEventOf(signedEvent)
|
||||
|
||||
return viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
withTimeout(BROADCAST_TIMEOUT) {
|
||||
publishTransport.publishEvent(
|
||||
nostrEvent = wireEvent,
|
||||
relays = targets.map { RelayDTO(url = it.url, read = false, write = true) }.toSet(),
|
||||
)
|
||||
// One answer per relay is all the pool ever gives, and the
|
||||
// flow underneath never completes on its own.
|
||||
.take(targets.size)
|
||||
.collect { result -> record(targets, result) }
|
||||
}
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
// Ordinary: a relay that never answers is what the timeout is
|
||||
// for. The rows still marked as sending are settled below.
|
||||
logger.w("Broadcast of ${signedEvent.id} ran out of time")
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
// The pool refused the whole call rather than one relay's part of
|
||||
// it, so every row that was waiting gets the same answer.
|
||||
logger.e("Broadcast of ${signedEvent.id} failed before any relay answered", e)
|
||||
targets.forEach { relay ->
|
||||
if (outcomes[relay] == RelayOutcome.Sending) {
|
||||
outcomes[relay] = RelayOutcome.Failed(e.message ?: "Could not send")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
targets.forEach { relay ->
|
||||
if (outcomes[relay] == RelayOutcome.Sending) outcomes[relay] = RelayOutcome.NoAnswer
|
||||
}
|
||||
|
||||
lastBroadcast = BroadcastSummary(
|
||||
accepted = targets.count { outcomes[it] == RelayOutcome.Accepted },
|
||||
asked = targets.size,
|
||||
)
|
||||
|
||||
isActionPending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Files one relay's answer against the row that asked it.
|
||||
*
|
||||
* Matched by host rather than by string, because the pool names a relay by
|
||||
* the URL it first opened a socket with, and that may carry a trailing slash
|
||||
* this list's spelling does not.
|
||||
*/
|
||||
private fun record(targets: List<NormalizedRelayUrl>, result: NostrPublishResult) {
|
||||
val answered = NormalizedRelayUrl(result.relayUrl).displayUrl()
|
||||
val relay = targets.firstOrNull { it.displayUrl() == answered } ?: run {
|
||||
logger.w("Answer from ${result.relayUrl}, which was not asked")
|
||||
return
|
||||
}
|
||||
|
||||
val error = result.error
|
||||
val ok = result.result as? NostrIncomingMessage.OkMessage
|
||||
|
||||
outcomes[relay] = when {
|
||||
// The pool wraps a relay's ["OK", id, false, reason] in this one
|
||||
// exception type and nothing else in it, so the message is the
|
||||
// relay's reason in the relay's words.
|
||||
error is NostrPublishException -> RelayOutcome.Rejected(error.message ?: "Rejected")
|
||||
error != null -> RelayOutcome.Failed(error.message ?: "Could not send")
|
||||
ok?.success == true -> RelayOutcome.Accepted
|
||||
else -> RelayOutcome.Failed("No OK from the relay")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BroadcastGroupSignedEventViewModel"
|
||||
|
||||
/**
|
||||
* A ceiling on the whole broadcast, past the pool's own per-relay wait,
|
||||
* so a socket that opens and then says nothing cannot hold the button
|
||||
* down forever. The same bound the queue drainer puts on one attempt.
|
||||
*/
|
||||
val BROADCAST_TIMEOUT = (RelayPool.PUBLISH_TIMEOUT * 2).milliseconds
|
||||
|
||||
/**
|
||||
* The event as the pool wants it, and nothing more.
|
||||
*
|
||||
* A `NostrEvent` built to be serialised and dropped -- it is never
|
||||
* written to the table, which is the whole point of this screen sending
|
||||
* directly rather than through the queue. The seven fields are the ones
|
||||
* the id was hashed over, so what goes out is what the group signed.
|
||||
*/
|
||||
fun wireEventOf(signedEvent: GroupSignedEvent): NostrEvent = NostrEvent(
|
||||
id = signedEvent.id,
|
||||
pubKey = signedEvent.publicKey,
|
||||
kind = signedEvent.kind,
|
||||
tags = signedEvent.tags,
|
||||
content = signedEvent.content,
|
||||
sig = signedEvent.signature,
|
||||
createdAt = signedEvent.createdAt,
|
||||
)
|
||||
|
||||
/**
|
||||
* Where an event of [kind] should go, before the member has said otherwise.
|
||||
*
|
||||
* The group's General list is the answer NIP-65 gives -- its write relays,
|
||||
* where it has agreed any -- and the app's own publish set stands in for a
|
||||
* group that has not, since a list of nothing would make the button a
|
||||
* no-op on every group until somebody edits it. Two kinds add to that:
|
||||
*
|
||||
* - A relay list goes to the indexers as well. The list says where to find
|
||||
* the group, so sending it only to the relays it names is circular -- a
|
||||
* reader who already knew those relays would not need the list.
|
||||
* - A curated schema goes to the relays it names for its own replies, so a
|
||||
* client reading suggestions there finds what they are replying to.
|
||||
*
|
||||
* And nothing goes to a relay the group has blocked, whatever the other
|
||||
* lists say; that is what a blocked list is for.
|
||||
*/
|
||||
fun defaultRelaysFor(
|
||||
relayLists: List<GroupRelayList>,
|
||||
kind: Kind,
|
||||
tags: Array<Array<String>> = emptyArray(),
|
||||
content: String = "",
|
||||
): List<NormalizedRelayUrl> {
|
||||
val general = relayLists.firstOrNull { it.set == GroupRelaySet.General }
|
||||
?.relays.orEmpty()
|
||||
.filter { it.write }
|
||||
.map { it.url }
|
||||
|
||||
val where = general.ifEmpty { Relays.eventPublishRelaySet.toList() }
|
||||
|
||||
val indexers = if (kind in GroupRelaySet.KINDS) {
|
||||
Relays.DefaultIndexerRelayList.toList()
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val schemaRelays = if (kind == CuratedSchemaEvent.KIND) {
|
||||
runCatching { CuratedSchemaEvent.read(tags, content).relays }
|
||||
.getOrDefault(emptyList())
|
||||
.mapNotNull { GroupRelaySet.relayUrlOrNull(it) }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val blocked = relayLists.firstOrNull { it.set == GroupRelaySet.Blocked }
|
||||
?.relays.orEmpty()
|
||||
.map { it.url }
|
||||
.toSet()
|
||||
|
||||
return (where + indexers + schemaRelays).distinct().filterNot { it in blocked }
|
||||
}
|
||||
|
||||
fun factory(
|
||||
activeUserPublicKey: HexKey,
|
||||
chatRoomId: String,
|
||||
relayHint: String?,
|
||||
signedEventId: HexKey,
|
||||
initialBroadcastGroupSignedEventUIState: BroadcastGroupSignedEventUIState =
|
||||
BroadcastGroupSignedEventUIState.Loading,
|
||||
chatRepository: ChatRepository,
|
||||
publishTransport: EventPublishTransport,
|
||||
): ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
BroadcastGroupSignedEventViewModel(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = chatRoomId,
|
||||
relayHint = relayHint,
|
||||
signedEventId = signedEventId,
|
||||
initialBroadcastGroupSignedEventUIState = initialBroadcastGroupSignedEventUIState,
|
||||
chatRepository = chatRepository,
|
||||
publishTransport = publishTransport,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package press.mantra.compose.ui.view.state
|
||||
|
||||
import press.mantra.compose.database.model.GroupSignedEvent
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.nostr.GroupRelayList
|
||||
|
||||
sealed interface BroadcastGroupSignedEventUIState {
|
||||
data class Loaded(
|
||||
val localChatRoom: LocalChatRoom,
|
||||
/** The event to send, as the group signed it. What goes on the wire is this, byte for byte. */
|
||||
val signedEvent: GroupSignedEvent,
|
||||
/**
|
||||
* Where the group has said it lives, which is what the relay picker starts
|
||||
* from: the General list's write relays when the group has agreed one, and
|
||||
* never a relay the group has blocked. See
|
||||
* `BroadcastGroupSignedEventViewModel.defaultRelaysFor`.
|
||||
*/
|
||||
val relayLists: List<GroupRelayList> = emptyList(),
|
||||
): BroadcastGroupSignedEventUIState
|
||||
|
||||
data class Error(
|
||||
val message: String
|
||||
): BroadcastGroupSignedEventUIState
|
||||
|
||||
data object Loading: BroadcastGroupSignedEventUIState
|
||||
}
|
||||
Reference in new issue
Block a user