From 07e1db950ab175324eb5aed84b6af26823046cb0 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 26 Jul 2026 00:17:17 +0200 Subject: [PATCH] Implement DatabaseMantraRepository.addArtifact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 -> ) so it returns the correct EventTemplate type. Co-Authored-By: Claude Opus 4.8 --- .../database/model/MantraArtifactVersion.kt | 26 ++++ .../repository/DatabaseMantraRepository.kt | 126 +++++++++++++----- .../nostr/nip30303/ArtifactVersionEvent.kt | 2 +- .../compose/repository/MantraRepository.kt | 37 +++-- .../ui/composable/AddArtifactScreen.kt | 3 + .../ui/view/model/AddArtifactViewModel.kt | 51 ++++--- 6 files changed, 184 insertions(+), 61 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifactVersion.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifactVersion.kt index 2630c976..1e7435ef 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifactVersion.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifactVersion.kt @@ -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, + 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( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt index 56195c34..68e3009a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt @@ -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" } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ArtifactVersionEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ArtifactVersionEvent.kt index 795ecc41..ca052f83 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ArtifactVersionEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ArtifactVersionEvent.kt @@ -35,7 +35,7 @@ class ArtifactVersionEvent( fun build( content: String, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, content, createdAt) { alt(ALT_DESCRIPTION) initializer() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt index 9e4e34df..e18bcf85 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt @@ -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 } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt index 61c56f46..c8ded66a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt @@ -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") diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt index 0f0f4aea..b4216029 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt @@ -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 } }