Implement DatabaseMantraRepository.addArtifact

addArtifact was a stub: it returned null unconditionally (so the
ViewModel's success path never fired), called a TODO() addArtifactVersion
that threw mid-flow, and passed empty chatRoomId/userPublicKey — yielding
a wrong event id and foreign-key violations.

- Persist the MantraArtifact with the real chatRoomId and userPublicKey,
  plus a MarmotInnerEvent rumor (marmotGroupEventId IS NULL) that the
  outbound pipeline encrypts into the group, mirroring sendChatMessage.
  Set the inner event kind to ArtifactEvent.KIND so it matches the hashed
  id (it previously defaulted to ChatEvent.KIND).
- Implement addArtifactVersion (was TODO) and create the initial version;
  add MantraArtifactVersion.fromArtifactVersionEventTemplate mirroring
  MantraArtifact for consistent id derivation.
- Return the created inner event so the caller can detect success; wrap
  writes in try/catch to fail without crashing.

Thread the required dialectId plus chatRoomId/userPublicKey and the
visibility/license params (defaulting to private/cc) through
MantraRepository and AddArtifactViewModel. The ViewModel now validates
inputs up front, routes exceptions to onFailure, clears fields only on
success, and uses isActionPending to block double submits. The screen
passes dialectId = null with a TODO until a dialect picker exists; the
flow fails gracefully in the meantime.

Also fix the ArtifactVersionEvent.build phantom generic
(TagArrayBuilder<ArtifactEvent> -> <ArtifactVersionEvent>) so it returns
the correct EventTemplate type.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-07-26 00:17:17 +02:00
parent 8e3c1cffe2
commit 07e1db950a
6 changed files with 184 additions and 61 deletions

View File

@@ -5,6 +5,8 @@ import androidx.room3.ForeignKey
import androidx.room3.PrimaryKey
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip31Alts.AltTag
import press.mantra.compose.database.model.traits.OptionalNostrEventEntity
import press.mantra.compose.database.model.traits.TimestampedEntity
@@ -81,6 +83,30 @@ data class MantraArtifactVersion(
}
companion object {
fun fromArtifactVersionEventTemplate(
artifactVersionEventTemplate: EventTemplate<ArtifactVersionEvent>,
chatRoomId: HexKey,
userPublicKey: HexKey,
): MantraArtifactVersion? {
return fromArtifactVersionEvent(
ArtifactVersionEvent(
id = EventHasher.hashId(
pubKey = userPublicKey,
tags = artifactVersionEventTemplate.tags,
content = artifactVersionEventTemplate.content,
createdAt = artifactVersionEventTemplate.createdAt,
kind = artifactVersionEventTemplate.kind,
),
content = artifactVersionEventTemplate.content,
tags = artifactVersionEventTemplate.tags,
createdAt = artifactVersionEventTemplate.createdAt,
pubKey = userPublicKey,
sig = "", // Unsigned rumor
),
chatRoomId = chatRoomId,
)
}
fun fromArtifactVersionEvent(artifactVersionEvent: ArtifactVersionEvent, chatRoomId: HexKey, ): MantraArtifactVersion? {
return artifactVersionEvent.artifactId()?.let { artifactId ->
MantraArtifactVersion(

View File

@@ -1,63 +1,123 @@
package press.mantra.compose.database.repository
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import kotlinx.coroutines.CoroutineScope
import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.model.MantraArtifact
import press.mantra.compose.database.model.MantraArtifactVersion
import press.mantra.compose.database.model.MarmotInnerEvent
import press.mantra.compose.nostr.nip30303.ArtifactEvent
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag
import press.mantra.compose.repository.MantraRepository
import kotlin.time.Instant
class DatabaseMantraRepository(
val database: MantraDatabase,
val scope: CoroutineScope
): MantraRepository {
override suspend fun addArtifact(name: String, url: String, versionLabel: String): MarmotInnerEvent? {
// Check that user is admin...
// TODO: Call mantraDao.addArtifact
private val logger = Logger.withTag(TAG)
override suspend fun addArtifact(
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(
name = name,
url = url,
visibility = "private",
license = "cc",
dialectId = "7de5ab261eac58a01bd46953b5a93b4f80e9501ab01c4adcc9338d8e7507a98d", // TODO: Get this as a paramter...
visibility = visibility,
license = license,
dialectId = dialectId,
)
// TODO: Save this as an marmotInner event...
MantraArtifact.fromArtifactEventTemplate(
val mantraArtifact = MantraArtifact.fromArtifactEventTemplate(
artifactEventTemplate = artifactEventTemplate,
chatRoomId = "",
userPublicKey = ""
)?.let { mantraArtifact ->
database.mantraArtifactDao().upsert(
mantraArtifact
)
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
) ?: return null
// If this is private we create a marmot inner event... if its permissioned we create a marmot publicMessage? if it's public we publish a nostr event?
database.marmotInnerEventDao().upsert(
MarmotInnerEvent(
id = mantraArtifact.id,
publicKey = mantraArtifact.publicKey,
createdAt = mantraArtifact.createdAt,
tags = artifactEventTemplate.tags,
content = artifactEventTemplate.content,
chatRoomId = mantraArtifact.chatRoomId
)
)
// 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,
)
// also create an artifactVersion
return try {
database.mantraArtifactDao().upsert(mantraArtifact)
database.marmotInnerEventDao().upsert(artifactInnerEvent)
// Every artifact starts with an initial version.
addArtifactVersion(
artifactId = mantraArtifact.id,
versionLabel = versionLabel
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(
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))
}
return null
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,
)
database.mantraArtifactVersionDao().upsert(mantraArtifactVersion)
database.marmotInnerEventDao().upsert(versionInnerEvent)
return versionInnerEvent
}
override suspend fun addArtifactVersion(artifactId: HexKey, versionLabel: String) {
TODO("Not yet implemented")
companion object {
private const val TAG = "DatabaseMantraRepository"
}
}
}

View File

@@ -35,7 +35,7 @@ class ArtifactVersionEvent(
fun build(
content: String,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<ArtifactEvent>.() -> Unit = {},
initializer: TagArrayBuilder<ArtifactVersionEvent>.() -> Unit = {},
) = eventTemplate(KIND, content, createdAt) {
alt(ALT_DESCRIPTION)
initializer()

View File

@@ -7,30 +7,43 @@ interface MantraRepository {
suspend fun addArtifact(
name: String,
url: String,
versionLabel: String
versionLabel: String,
dialectId: HexKey,
chatRoomId: String,
userPublicKey: HexKey,
visibility: String = DEFAULT_VISIBILITY,
license: String = DEFAULT_LICENSE,
): MarmotInnerEvent?
suspend fun addArtifactVersion(
artifactId: HexKey,
versionLabel: String
)
versionLabel: String,
chatRoomId: String,
userPublicKey: HexKey,
): MarmotInnerEvent?
companion object {
const val DEFAULT_VISIBILITY = "private"
const val DEFAULT_LICENSE = "cc"
val NO_OP_MANTRA_REPOSITORY = object: MantraRepository{
override suspend fun addArtifact(
name: String,
url: String,
versionLabel: String
): MarmotInnerEvent? {
TODO("Not yet implemented")
}
versionLabel: String,
dialectId: HexKey,
chatRoomId: String,
userPublicKey: HexKey,
visibility: String,
license: String,
): MarmotInnerEvent? = null
override suspend fun addArtifactVersion(
artifactId: HexKey,
versionLabel: String
) {
TODO("Not yet implemented")
}
versionLabel: String,
chatRoomId: String,
userPublicKey: HexKey,
): MarmotInnerEvent? = null
}
}
}
}

View File

@@ -121,6 +121,9 @@ fun AddArtifactScreen(
nameField = nameFieldState,
urlField = urlFieldState,
versionLabelField = versionLabelFieldState,
// TODO: Source this from a dialect picker; addArtifact
// fails gracefully while this is null.
dialectId = null,
onSuccess = {
onNavigateToRoute.invoke(
ImplementationPendingRoute("Add Artifact")

View File

@@ -62,33 +62,54 @@ class AddArtifactViewModel(
nameField: TextFieldState,
urlField: TextFieldState,
versionLabelField: TextFieldState,
dialectId: HexKey?,
visibility: String = MantraRepository.DEFAULT_VISIBILITY,
license: String = MantraRepository.DEFAULT_LICENSE,
onSuccess: () -> Unit,
onFailure: () -> Unit
) {
val name = nameField.text.toString()
val url = urlField.text.toString()
val versionLabel = versionLabelField.text.toString()
if (nameField.text.isNotBlank() && urlField.text.isNotBlank() && versionLabelField.text.isNotBlank()) {
val name = nameField.text.toString()
val url = urlField.text.toString()
val versionLabel = versionLabelField.text.toString()
// dialectId is a required foreign key on MantraArtifact. Until a dialect
// picker supplies one, this validation fails gracefully instead of
// attempting an insert that would violate the constraint.
if (name.isBlank() || url.isBlank() || versionLabel.isBlank() || dialectId.isNullOrBlank()) {
onFailure.invoke()
return
}
nameField.clearText()
urlField.clearText()
versionLabelField.clearText()
// Guard against double submits from repeated FAB taps.
if (isActionPending.value) return
isActionPending.value = true
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(Dispatchers.IO) {
val marmotInnerEvent = runCatching {
mantraRepository.addArtifact(
name = name,
url = url,
versionLabel = versionLabel
)?.let { marmotInnerEvent ->
onSuccess.invoke()
return@launch
}
versionLabel = versionLabel,
dialectId = dialectId,
chatRoomId = localChatRoom.chatRoom.id,
userPublicKey = activeUserPublicKey,
visibility = visibility,
license = license,
)
}.onFailure { error ->
logger.e("Failed to add artifact", error)
}.getOrNull()
if (marmotInnerEvent != null) {
nameField.clearText()
urlField.clearText()
versionLabelField.clearText()
onSuccess.invoke()
} else {
onFailure.invoke()
}
isActionPending.value = false
}
}