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 } }