Send artifacts inner events

This commit is contained in:
Kgothatso Ngako
2026-07-28 00:52:21 +02:00
parent 0c65f61ea2
commit 33c4d1000f
6 changed files with 327 additions and 179 deletions

View File

@@ -22,6 +22,7 @@ import press.mantra.compose.database.dao.MantraArtifactDao
import press.mantra.compose.database.dao.MantraArtifactVersionDao
import press.mantra.compose.database.dao.MantraChapterDao
import press.mantra.compose.database.dao.MantraChunkDao
import press.mantra.compose.database.dao.MantraDao
import press.mantra.compose.database.dao.MantraDialectDao
import press.mantra.compose.database.dao.MantraTranslationArtifactVersionDao
import press.mantra.compose.database.dao.MantraTranslationArtifactVersionContributorDao
@@ -184,6 +185,8 @@ abstract class MantraDatabase: RoomDatabase() {
abstract fun inReplyToRelationDao(): InReplyToRelationDao
abstract fun mantraDao(): MantraDao
abstract fun mantraArtifactDao(): MantraArtifactDao
abstract fun mantraArtifactVersionDao(): MantraArtifactVersionDao

View File

@@ -0,0 +1,220 @@
package press.mantra.compose.database.dao
import androidx.room3.Transaction
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.database.model.ChatMessage
import press.mantra.compose.database.model.MantraArtifact
import press.mantra.compose.database.model.MantraArtifactVersion
import press.mantra.compose.database.model.MantraDialect
import press.mantra.compose.database.model.MarmotInnerEvent
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.nostr.nip30303.ArtifactEvent
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.DialectEvent
import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag
import press.mantra.compose.repository.MantraRepository.Companion.DEFAULT_LICENSE
import press.mantra.compose.repository.MantraRepository.Companion.DEFAULT_VISIBILITY
abstract class MantraDao(
private val database: press.mantra.compose.database.MantraDatabase,
) {
val logger = Logger.withTag("NostrDao")
@Transaction
open suspend fun addDialect(
localChatRoom: LocalChatRoom,
name: String,
country: String,
language: String,
userPublicKey: HexKey,
): MarmotInnerEvent? {
logger.d("addDialect: $name")
val dialectEventTemplate = DialectEvent.build(
name = name,
country = country,
language = language,
)
val mantraDialect = MantraDialect.fromDialectEventTemplate(
dialectEventTemplate = dialectEventTemplate,
chatRoomId = localChatRoom.chatRoom.id,
userPublicKey = userPublicKey,
) ?: return null
val dialectInnerEvent = MarmotInnerEvent(
id = mantraDialect.id,
publicKey = mantraDialect.publicKey,
kind = DialectEvent.KIND,
createdAt = mantraDialect.createdAt,
tags = dialectEventTemplate.tags,
content = dialectEventTemplate.content,
chatRoomId = mantraDialect.chatRoomId,
)
return try {
database.mantraDialectDao().upsert(mantraDialect)
database.marmotInnerEventDao().upsert(dialectInnerEvent)
sendMarmotInnerEvent(
localChatRoom = localChatRoom,
text = "Added $name as a dialect",
marmotInnerEvent = dialectInnerEvent
)
dialectInnerEvent
} catch (error: Throwable) {
logger.e("Failed to add dialect \"$name\" to chat room ${localChatRoom.chatRoom.id}", error)
null
}
}
@Transaction
open suspend fun addArtifact(
localChatRoom: LocalChatRoom,
name: String,
url: String,
versionLabel: String,
dialectId: HexKey,
userPublicKey: HexKey,
visibility: String = DEFAULT_VISIBILITY,
license: String = DEFAULT_LICENSE,
): MarmotInnerEvent? {
// TODO: Verify the active user is an admin of the chat room before allowing this.
val artifactEventTemplate = ArtifactEvent.build(
name = name,
url = url,
visibility = visibility,
license = license,
dialectId = dialectId,
)
val mantraArtifact = MantraArtifact.fromArtifactEventTemplate(
artifactEventTemplate = artifactEventTemplate,
chatRoomId = localChatRoom.chatRoom.id,
userPublicKey = userPublicKey,
) ?: return null
// Persist the artifact together with an unprocessed marmot inner event
// (a rumor). Inner events with a null marmotGroupEventId are later
// picked up by the outbound pipeline and encrypted into a kind:445
// group event for the chat room.
//
// This covers the "private" visibility case. Permissioned artifacts
// (published as a PublicMessage) and public artifacts (published as a
// plain nostr event) are not implemented yet.
val artifactInnerEvent = MarmotInnerEvent(
id = mantraArtifact.id,
publicKey = mantraArtifact.publicKey,
kind = ArtifactEvent.KIND,
createdAt = mantraArtifact.createdAt,
tags = artifactEventTemplate.tags,
content = artifactEventTemplate.content,
chatRoomId = mantraArtifact.chatRoomId,
)
return try {
database.mantraArtifactDao().upsert(mantraArtifact)
database.marmotInnerEventDao().upsert(artifactInnerEvent)
sendMarmotInnerEvent(
localChatRoom = localChatRoom,
text = "Added $name to artifacts",
marmotInnerEvent = artifactInnerEvent
)
// Every artifact starts with an initial version.
addArtifactVersionInternal(
localChatRoom = localChatRoom,
artifactId = mantraArtifact.id,
versionLabel = versionLabel,
userPublicKey = userPublicKey,
)
artifactInnerEvent
} catch (error: Throwable) {
logger.e("Failed to add artifact \"$name\" to chat room ${localChatRoom.chatRoom.id}", error)
null
}
}
@Transaction
open suspend fun addArtifactVersion(
localChatRoom: LocalChatRoom,
artifactId: HexKey,
versionLabel: String,
userPublicKey: HexKey,
): MarmotInnerEvent? {
return addArtifactVersionInternal(
localChatRoom = localChatRoom,
artifactId = artifactId,
versionLabel = versionLabel,
userPublicKey = userPublicKey
)
}
private suspend fun addArtifactVersionInternal(
localChatRoom: LocalChatRoom,
artifactId: HexKey,
versionLabel: String,
userPublicKey: HexKey,
): MarmotInnerEvent? {
// The version label is carried in the event content (see
// MantraArtifactVersion.fromArtifactVersionEvent).
val artifactVersionEventTemplate = ArtifactVersionEvent.build(
content = versionLabel,
) {
addUnique(ArtifactIdTag.assemble(artifactId))
}
val mantraArtifactVersion = MantraArtifactVersion.fromArtifactVersionEventTemplate(
artifactVersionEventTemplate = artifactVersionEventTemplate,
chatRoomId = localChatRoom.chatRoom.id,
userPublicKey = userPublicKey,
) ?: return null
val versionInnerEvent = MarmotInnerEvent(
id = mantraArtifactVersion.id,
publicKey = mantraArtifactVersion.publicKey,
kind = ArtifactVersionEvent.KIND,
createdAt = mantraArtifactVersion.createdAt,
tags = artifactVersionEventTemplate.tags,
content = artifactVersionEventTemplate.content,
chatRoomId = mantraArtifactVersion.chatRoomId,
)
database.mantraArtifactVersionDao().upsert(mantraArtifactVersion)
database.marmotInnerEventDao().upsert(versionInnerEvent)
sendMarmotInnerEvent(
localChatRoom = localChatRoom,
text = "Add the ${artifactVersionEventTemplate.content} version",
marmotInnerEvent = versionInnerEvent
)
return versionInnerEvent
}
private suspend fun sendMarmotInnerEvent(
localChatRoom: LocalChatRoom,
text: String,
marmotInnerEvent: MarmotInnerEvent
) {
database.marmotInnerEventDao().upsert(
marmotInnerEvent
)
database.chatMessageDao().upsert(
ChatMessage(
content = text,
chatRoomId = localChatRoom.chatRoom.id,
senderPublicKey = localChatRoom.chatRoom.userPublicKey,
isUserMessage = true,
giftWrapPayloadId = null,
marmotGroupEventId = null,
marmotInnerEventId = marmotInnerEvent.id,
)
)
// TODO: Depending on visibility (if signature is non-empty) might want to queue this event for broadcasting...
}
}

View File

@@ -4,6 +4,7 @@ import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.CoroutineScope
import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.model.ChatMessage
import press.mantra.compose.database.model.MantraArtifact
import press.mantra.compose.database.model.MantraArtifactVersion
import press.mantra.compose.database.model.MantraChapter
@@ -13,11 +14,10 @@ import press.mantra.compose.database.model.MantraTranslationArtifactVersion
import press.mantra.compose.database.model.MantraTranslationChapter
import press.mantra.compose.database.model.MantraTranslationChunk
import press.mantra.compose.database.model.MarmotInnerEvent
import press.mantra.compose.nostr.nip30303.ArtifactEvent
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.ChapterEvent
import press.mantra.compose.nostr.nip30303.ChunkEvent
import press.mantra.compose.nostr.nip30303.DialectEvent
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
@@ -318,140 +318,55 @@ class DatabaseMantraRepository(
database.mantraDialectDao().getDialectById(id)
override suspend fun addDialect(
localChatRoom: LocalChatRoom,
name: String,
country: String,
language: String,
chatRoomId: String,
userPublicKey: HexKey,
): MantraDialect? {
val dialectEventTemplate = DialectEvent.build(
): MarmotInnerEvent? {
return database.mantraDao().addDialect(
localChatRoom = localChatRoom,
name = name,
country = country,
language = language,
userPublicKey = userPublicKey
)
val mantraDialect = MantraDialect.fromDialectEventTemplate(
dialectEventTemplate = dialectEventTemplate,
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
) ?: return null
val dialectInnerEvent = MarmotInnerEvent(
id = mantraDialect.id,
publicKey = mantraDialect.publicKey,
kind = DialectEvent.KIND,
createdAt = mantraDialect.createdAt,
tags = dialectEventTemplate.tags,
content = dialectEventTemplate.content,
chatRoomId = mantraDialect.chatRoomId,
)
return try {
database.mantraDialectDao().upsert(mantraDialect)
database.marmotInnerEventDao().upsert(dialectInnerEvent)
mantraDialect
} catch (error: Throwable) {
logger.e("Failed to add dialect \"$name\" to chat room $chatRoomId", error)
null
}
}
override suspend fun addArtifact(
localChatRoom: LocalChatRoom,
name: String,
url: String,
versionLabel: String,
dialectId: HexKey,
chatRoomId: String,
userPublicKey: HexKey,
visibility: String,
license: String,
): MarmotInnerEvent? {
// TODO: Verify the active user is an admin of the chat room before allowing this.
val artifactEventTemplate = ArtifactEvent.build(
return database.mantraDao().addArtifact(
localChatRoom = localChatRoom,
name = name,
url = url,
visibility = visibility,
license = license,
versionLabel = versionLabel,
dialectId = dialectId,
)
val mantraArtifact = MantraArtifact.fromArtifactEventTemplate(
artifactEventTemplate = artifactEventTemplate,
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
) ?: return null
// Persist the artifact together with an unprocessed marmot inner event
// (a rumor). Inner events with a null marmotGroupEventId are later
// picked up by the outbound pipeline and encrypted into a kind:445
// group event for the chat room.
//
// This covers the "private" visibility case. Permissioned artifacts
// (published as a PublicMessage) and public artifacts (published as a
// plain nostr event) are not implemented yet.
val artifactInnerEvent = MarmotInnerEvent(
id = mantraArtifact.id,
publicKey = mantraArtifact.publicKey,
kind = ArtifactEvent.KIND,
createdAt = mantraArtifact.createdAt,
tags = artifactEventTemplate.tags,
content = artifactEventTemplate.content,
chatRoomId = mantraArtifact.chatRoomId,
visibility = visibility,
license = license
)
return try {
database.mantraArtifactDao().upsert(mantraArtifact)
database.marmotInnerEventDao().upsert(artifactInnerEvent)
// Every artifact starts with an initial version.
addArtifactVersion(
artifactId = mantraArtifact.id,
versionLabel = versionLabel,
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
)
artifactInnerEvent
} catch (error: Throwable) {
logger.e("Failed to add artifact \"$name\" to chat room $chatRoomId", error)
null
}
}
override suspend fun addArtifactVersion(
localChatRoom: LocalChatRoom,
artifactId: HexKey,
versionLabel: String,
chatRoomId: String,
userPublicKey: HexKey,
): MarmotInnerEvent? {
// The version label is carried in the event content (see
// MantraArtifactVersion.fromArtifactVersionEvent).
val artifactVersionEventTemplate = ArtifactVersionEvent.build(
content = versionLabel,
) {
addUnique(ArtifactIdTag.assemble(artifactId))
}
val mantraArtifactVersion = MantraArtifactVersion.fromArtifactVersionEventTemplate(
artifactVersionEventTemplate = artifactVersionEventTemplate,
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
) ?: return null
val versionInnerEvent = MarmotInnerEvent(
id = mantraArtifactVersion.id,
publicKey = mantraArtifactVersion.publicKey,
kind = ArtifactVersionEvent.KIND,
createdAt = mantraArtifactVersion.createdAt,
tags = artifactVersionEventTemplate.tags,
content = artifactVersionEventTemplate.content,
chatRoomId = mantraArtifactVersion.chatRoomId,
return database.mantraDao().addArtifactVersion(
localChatRoom = localChatRoom,
artifactId = artifactId,
versionLabel = versionLabel,
userPublicKey = userPublicKey
)
database.mantraArtifactVersionDao().upsert(mantraArtifactVersion)
database.marmotInnerEventDao().upsert(versionInnerEvent)
return versionInnerEvent
}
companion object {

View File

@@ -10,6 +10,7 @@ import press.mantra.compose.database.model.MantraTranslationArtifactVersion
import press.mantra.compose.database.model.MantraTranslationChapter
import press.mantra.compose.database.model.MantraTranslationChunk
import press.mantra.compose.database.model.MarmotInnerEvent
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
interface MantraRepository {
suspend fun getArtifacts(chatRoomId: String): List<MantraArtifact>
@@ -85,28 +86,28 @@ interface MantraRepository {
suspend fun getDialect(id: String): MantraDialect?
suspend fun addDialect(
localChatRoom: LocalChatRoom,
name: String,
country: String,
language: String,
chatRoomId: String,
userPublicKey: HexKey,
): MantraDialect?
): MarmotInnerEvent?
suspend fun addArtifact(
localChatRoom: LocalChatRoom,
name: String,
url: String,
versionLabel: String,
dialectId: HexKey,
chatRoomId: String,
userPublicKey: HexKey,
visibility: String = DEFAULT_VISIBILITY,
license: String = DEFAULT_LICENSE,
): MarmotInnerEvent?
suspend fun addArtifactVersion(
localChatRoom: LocalChatRoom,
artifactId: HexKey,
versionLabel: String,
chatRoomId: String,
userPublicKey: HexKey,
): MarmotInnerEvent?
@@ -169,28 +170,28 @@ interface MantraRepository {
override suspend fun getDialect(id: String): MantraDialect? = null
override suspend fun addDialect(
localChatRoom: LocalChatRoom,
name: String,
country: String,
language: String,
chatRoomId: String,
userPublicKey: HexKey,
): MantraDialect? = null
): MarmotInnerEvent? = null
override suspend fun addArtifact(
localChatRoom: LocalChatRoom,
name: String,
url: String,
versionLabel: String,
dialectId: HexKey,
chatRoomId: String,
userPublicKey: HexKey,
visibility: String,
license: String,
): MarmotInnerEvent? = null
override suspend fun addArtifactVersion(
localChatRoom: LocalChatRoom,
artifactId: HexKey,
versionLabel: String,
chatRoomId: String,
userPublicKey: HexKey,
): MarmotInnerEvent? = null
}

View File

@@ -14,13 +14,14 @@ import androidx.lifecycle.viewmodel.viewModelFactory
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.NostrRepository
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.launch
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.repository.MantraRepository
import press.mantra.compose.repository.MarmotRepository
import press.mantra.compose.ui.view.state.AddArtifactUIState
class AddArtifactViewModel(
@@ -72,75 +73,83 @@ class AddArtifactViewModel(
onSuccess: (artifactId: String) -> Unit,
onFailure: () -> Unit
) {
val name = nameField.text.toString()
val url = urlField.text.toString()
val versionLabel = versionLabelField.text.toString()
val dialectName = dialectNameField.text.toString()
val dialectCountry = dialectCountryField.text.toString()
val dialectLanguage = dialectLanguageField.text.toString()
// When no existing dialect is selected, the new-dialect fields are required.
val creatingNewDialect = existingDialectId.isNullOrBlank()
val newDialectIncomplete = dialectName.isBlank() || dialectCountry.isBlank() || dialectLanguage.isBlank()
if (name.isBlank() || url.isBlank() || versionLabel.isBlank() ||
(creatingNewDialect && newDialectIncomplete)
) {
onFailure.invoke()
return
}
// Guard against double submits from repeated FAB taps.
if (isActionPending.value) return
isActionPending.value = true
viewModelScope.launch(Dispatchers.IO) {
val marmotInnerEvent = runCatching {
// Reuse the selected dialect, or create a new source dialect and
// reference it by id. addArtifact requires a valid dialectId.
val dialectId = if (creatingNewDialect) {
mantraRepository.addDialect(
name = dialectName,
country = dialectCountry,
language = dialectLanguage,
chatRoomId = localChatRoom.chatRoom.id,
userPublicKey = activeUserPublicKey,
)?.id ?: return@runCatching null
} else {
existingDialectId!!
}
mantraRepository.addArtifact(
name = name,
url = url,
versionLabel = versionLabel,
dialectId = dialectId,
chatRoomId = localChatRoom.chatRoom.id,
userPublicKey = activeUserPublicKey,
visibility = visibility,
license = license,
localChatRoom.chatRoom.mlsGroupState?.let { mlsGroupState ->
MlsGroup.restore(
MlsGroupState.decodeTls(
mlsGroupState.hexToByteArray()
)
}.onFailure { error ->
logger.e("Failed to add artifact", error)
}.getOrNull()
)
}?.let { mlsGroup ->
val name = nameField.text.toString()
val url = urlField.text.toString()
val versionLabel = versionLabelField.text.toString()
val dialectName = dialectNameField.text.toString()
val dialectCountry = dialectCountryField.text.toString()
val dialectLanguage = dialectLanguageField.text.toString()
if (marmotInnerEvent != null) {
nameField.clearText()
urlField.clearText()
versionLabelField.clearText()
dialectNameField.clearText()
dialectCountryField.clearText()
dialectLanguageField.clearText()
viewModelScope.launch(Dispatchers.Main) {
onSuccess.invoke(marmotInnerEvent.id)
}
} else {
viewModelScope.launch(Dispatchers.Main) {
onFailure.invoke()
}
// When no existing dialect is selected, the new-dialect fields are required.
val creatingNewDialect = existingDialectId.isNullOrBlank()
val newDialectIncomplete = dialectName.isBlank() || dialectCountry.isBlank() || dialectLanguage.isBlank()
if (name.isBlank() || url.isBlank() || versionLabel.isBlank() ||
(creatingNewDialect && newDialectIncomplete)
) {
onFailure.invoke()
return
}
isActionPending.value = false
// Guard against double submits from repeated FAB taps.
if (isActionPending.value) return
isActionPending.value = true
viewModelScope.launch(Dispatchers.IO) {
val artifactInnerEvent = runCatching {
// Reuse the selected dialect, or create a new source dialect and
// reference it by id. addArtifact requires a valid dialectId.
val dialectId = if (creatingNewDialect) {
mantraRepository.addDialect(
localChatRoom = localChatRoom,
name = dialectName,
country = dialectCountry,
language = dialectLanguage,
userPublicKey = activeUserPublicKey,
)?.id ?: return@runCatching null
} else {
existingDialectId
}
mantraRepository.addArtifact(
localChatRoom = localChatRoom,
name = name,
url = url,
versionLabel = versionLabel,
dialectId = dialectId,
userPublicKey = activeUserPublicKey,
visibility = visibility,
license = license,
)
}.onFailure { error ->
logger.e("Failed to add artifact", error)
}.getOrNull()
if (artifactInnerEvent != null) {
nameField.clearText()
urlField.clearText()
versionLabelField.clearText()
dialectNameField.clearText()
dialectCountryField.clearText()
dialectLanguageField.clearText()
viewModelScope.launch(Dispatchers.Main) {
onSuccess.invoke(artifactInnerEvent.id)
}
} else {
viewModelScope.launch(Dispatchers.Main) {
onFailure.invoke()
}
}
isActionPending.value = false
}
}
}

View File

@@ -81,10 +81,10 @@ class AddTranslationViewModel(
// Reuse the selected dialect, or create a new one for the translation.
val dialectId = if (creatingNewDialect) {
mantraRepository.addDialect(
,
name = dialectName,
country = dialectCountry,
language = dialectLanguage,
chatRoomId = chatRoomId,
userPublicKey = activeUserPublicKey,
)?.id ?: return@runCatching null
} else {