Merge branch 'mantra' into claude/frost-proposal-review-visibility-8f6fab

This commit is contained in:
Kgothatso Ngako
2026-09-06 01:13:47 +02:00
3 changed files with 183 additions and 14 deletions

View File

@@ -22,6 +22,7 @@ import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.extensions.exporterSecret
import press.mantra.compose.extensions.toHex
import press.mantra.compose.managers.MarmotInboundManager.EPOCH_RETENTION_WINDOW
import press.mantra.compose.nostr.MarmotDelivery
import press.mantra.compose.nostr.MarmotDirectMessage
import press.mantra.compose.nostr.Relays
import co.touchlab.kermit.Logger
@@ -653,13 +654,24 @@ abstract class MarmotOutboundDao(
// Keyed on the queued row, not on `innerEvent.id`. For a direct message those
// differ -- the row is the rumor, the wire event is the wrap built around it --
// and the wrap's id matches no ChatMessage, so this lookup would come back null,
// the message would never be linked, and no BroadcastNostrEventRequest would ever
// be inserted. Encrypted, stored, and silently never sent. They are the same value
// for every other kind of message.
// and the wrap's id matches no ChatMessage, so this lookup comes back null. That
// costs only the transcript linkage, not the send.
val chatMessageOrNull = database.chatMessageDao().getChatMessagesByMarmotInnerEventId(marmotInnerEvent.id)
logger.d("chatMessageOrNull: $chatMessageOrNull")
val delivery = MarmotDelivery.plan(
groupEventId = groupEvent.id,
relays = Relays.DefaultDMRelayList, // TODO: Get these from localChatRoom...
chatMessage = chatMessageOrNull,
)
// Sync broadcast to all the required relays. Unconditional, and before any
// transcript bookkeeping: see MarmotDelivery for what gating it on a chat line
// cost the signing sessions that have none.
val broadcastNostrEventRequestIds =
database.broadcastNostrEventRequestDao().insert(delivery.broadcasts)
chatMessageOrNull?.let { chatMessage ->
database.chatMessageNostrEventRelationDao().upsert(
ChatMessageNostrEventRelation(
@@ -674,16 +686,6 @@ abstract class MarmotOutboundDao(
)
)
// Sync broadcast to all the required relays...
val broadcastNostrEventRequestIds = database.broadcastNostrEventRequestDao().insert(
Relays.DefaultDMRelayList.map { // TODO: Get these from localChatRoom...
BroadcastNostrEventRequest(
nostrEventId = groupEvent.id,
relayURL = it.url
)
}
)
broadcastNostrEventRequestIds.forEach { broadcastNostrEventRequestId ->
database.chatMessageBroadcastNostrEventRequestRelationDao().upsert(
ChatMessageBroadcastNostrEventRequestRelation(

View File

@@ -0,0 +1,60 @@
package press.mantra.compose.nostr
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import press.mantra.compose.database.model.BroadcastNostrEventRequest
import press.mantra.compose.database.model.ChatMessage
/**
* What has to be written once a queued inner event has been encrypted into a
* group event: where it is sent, and which transcript row -- if any -- the send
* belongs to.
*
* The two are independent, and this exists to say so. A group event is sent
* because it was queued; a chat line is linked because a member said something.
* Letting the second decide the first is what silenced FROST signing: the
* broadcast rows were written inside `chatMessageOrNull?.let { }`, and protocol
* traffic has no ChatMessage -- `ChatMessage.applyInnerEvent` returns null for
* every [press.mantra.compose.nostr.frost.FrostSigningEvents] kind and for
* [press.mantra.compose.nostr.frost.GroupKeyStateEvent], and
* `FrostSigningManager` queues its messages without one. So every signing
* proposal was MLS-encrypted, wrapped, persisted and marked processed, and then
* never sent to a relay. Nothing failed; the other participants simply never saw
* it.
*
* Pulled out of `MarmotOutboundDao.encryptAndSendMarmotInnerEvent` because that
* function is Room-backed and cannot be unit tested, which is precisely how the
* gate survived. The decision is separated from the filing of it for the same
* reason [MarmotDirectMessage.classify] is.
*/
data class MarmotDelivery(
/**
* One request per relay, always. An empty list here means the event is on
* disk and going nowhere.
*/
val broadcasts: List<BroadcastNostrEventRequest>,
/**
* The chat line this send belongs to, or null when the row is protocol
* traffic -- something the room did rather than something a member said.
*/
val transcriptChatMessageId: Long?,
) {
/** True when nothing in the chat points at this event, which is not a reason to withhold it. */
val isProtocolTraffic: Boolean get() = transcriptChatMessageId == null
companion object {
fun plan(
groupEventId: HexKey,
relays: Collection<NormalizedRelayUrl>,
chatMessage: ChatMessage?,
): MarmotDelivery = MarmotDelivery(
broadcasts = relays.map { relay ->
BroadcastNostrEventRequest(
nostrEventId = groupEventId,
relayURL = relay.url,
)
},
transcriptChatMessageId = chatMessage?.id,
)
}
}