Merge pull request #2449 from vitorpamplona/claude/add-favorite-dvm-filter-DfveW

Add NIP-90 DVM favorite list support with content discovery
This commit is contained in:
Vitor Pamplona
2026-04-19 15:04:42 -04:00
committed by GitHub
36 changed files with 2583 additions and 154 deletions
@@ -0,0 +1,192 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.core.fastAny
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
import com.vitorpamplona.quartz.nip51Lists.remove
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class FavoriteAlgoFeedsListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun publicFavoriteAlgoFeeds(): List<AddressBookmark> = tags.mapNotNull(AddressBookmark::parse)
suspend fun privateFavoriteAlgoFeeds(signer: NostrSigner): List<AddressBookmark>? = privateTags(signer)?.mapNotNull(AddressBookmark::parse)
companion object {
const val KIND = 10090
const val ALT = "Favorite algo-feeds list"
const val FIXED_D_TAG = ""
fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG)
suspend fun create(
feed: AddressBookmark,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): FavoriteAlgoFeedsListEvent =
if (isPrivate) {
create(
publicFeeds = emptyList(),
privateFeeds = listOf(feed),
signer = signer,
createdAt = createdAt,
)
} else {
create(
publicFeeds = listOf(feed),
privateFeeds = emptyList(),
signer = signer,
createdAt = createdAt,
)
}
suspend fun add(
earlierVersion: FavoriteAlgoFeedsListEvent,
feed: AddressBookmark,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): FavoriteAlgoFeedsListEvent =
if (isPrivate) {
val privateTags =
earlierVersion.privateTags(signer)
?: throw SignerExceptions.UnauthorizedDecryptionException()
resign(
tags = earlierVersion.tags,
privateTags = privateTags.remove(feed.toTagIdOnly()) + feed.toTagArray(),
signer = signer,
createdAt = createdAt,
)
} else {
resign(
content = earlierVersion.content,
tags = earlierVersion.tags.remove(feed.toTagIdOnly()) + feed.toTagArray(),
signer = signer,
createdAt = createdAt,
)
}
suspend fun remove(
earlierVersion: FavoriteAlgoFeedsListEvent,
feed: Address,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): FavoriteAlgoFeedsListEvent {
val idOnly = AddressBookmark.assemble(feed, null)
val privateTags = earlierVersion.privateTags(signer)
return if (privateTags != null) {
resign(
privateTags = privateTags.remove(idOnly),
tags = earlierVersion.tags.remove(idOnly),
signer = signer,
createdAt = createdAt,
)
} else {
resign(
content = earlierVersion.content,
tags = earlierVersion.tags.remove(idOnly),
signer = signer,
createdAt = createdAt,
)
}
}
suspend fun resign(
tags: TagArray,
privateTags: TagArray,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
) = resign(
content = PrivateTagsInContent.encryptNip44(privateTags, signer),
tags = tags,
signer = signer,
createdAt = createdAt,
)
suspend fun resign(
content: String,
tags: TagArray,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): FavoriteAlgoFeedsListEvent {
val newTags =
if (tags.fastAny(AltTag::match)) {
tags
} else {
tags + AltTag.assemble(ALT)
}
return signer.sign(createdAt, KIND, newTags, content)
}
suspend fun create(
publicFeeds: List<AddressBookmark> = emptyList(),
privateFeeds: List<AddressBookmark> = emptyList(),
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): FavoriteAlgoFeedsListEvent {
val template = build(publicFeeds, privateFeeds, signer, createdAt)
return signer.sign(template)
}
suspend fun build(
publicFeeds: List<AddressBookmark> = emptyList(),
privateFeeds: List<AddressBookmark> = emptyList(),
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<FavoriteAlgoFeedsListEvent>.() -> Unit = {},
) = eventTemplate<FavoriteAlgoFeedsListEvent>(
kind = KIND,
description =
PrivateTagsInContent.encryptNip44(
privateFeeds.map { it.toTagArray() }.toTypedArray(),
signer,
),
createdAt = createdAt,
) {
alt(ALT)
favoriteAlgoFeeds(publicFeeds)
initializer()
}
}
}
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
fun TagArrayBuilder<FavoriteAlgoFeedsListEvent>.favoriteAlgoFeed(app: AddressBookmark) = add(app.toTagArray())
fun TagArrayBuilder<FavoriteAlgoFeedsListEvent>.favoriteAlgoFeeds(apps: List<AddressBookmark>) = addAll(apps.map { it.toTagArray() })
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
fun TagArray.favoriteAlgoFeedsList() = mapNotNull(AddressBookmark::parseAddress)
fun TagArray.favoriteAlgoFeedsSet() = mapNotNullTo(mutableSetOf(), AddressBookmark::parseAddress)
@@ -134,6 +134,7 @@ import com.vitorpamplona.quartz.nip51Lists.appCurationSet.AppCurationSetEvent
import com.vitorpamplona.quartz.nip51Lists.articleCurationSet.ArticleCurationSetEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.gitAuthorList.GitAuthorListEvent
@@ -422,6 +423,7 @@ class EventFactory {
GoodWikiAuthorListEvent.KIND -> GoodWikiAuthorListEvent(id, pubKey, createdAt, tags, content, sig)
GoodWikiRelayListEvent.KIND -> GoodWikiRelayListEvent(id, pubKey, createdAt, tags, content, sig)
GoalEvent.KIND -> GoalEvent(id, pubKey, createdAt, tags, content, sig)
FavoriteAlgoFeedsListEvent.KIND -> FavoriteAlgoFeedsListEvent(id, pubKey, createdAt, tags, content, sig)
HashtagListEvent.KIND -> HashtagListEvent(id, pubKey, createdAt, tags, content, sig)
HighlightEvent.KIND -> HighlightEvent(id, pubKey, createdAt, tags, content, sig)
HTTPAuthorizationEvent.KIND -> HTTPAuthorizationEvent(id, pubKey, createdAt, tags, content, sig)
@@ -0,0 +1,159 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
import com.vitorpamplona.quartz.utils.nsecToKeyPair
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class FavoriteAlgoFeedsListEventTest {
private val signer = NostrSignerInternal("nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair())
private fun dvm(
pubkey: String,
dTag: String = "content-discovery",
) = AddressBookmark(Address(31990, pubkey, dTag))
@Test
fun kindMatchesSpec() {
assertEquals(10090, FavoriteAlgoFeedsListEvent.KIND)
}
@Test
fun addressesAreReplaceableWithFixedDTag() {
val address = FavoriteAlgoFeedsListEvent.createAddress("a".repeat(64))
assertEquals(10090, address.kind)
assertEquals("", address.dTag)
}
@Test
fun createStoresDvmAsATag() =
runTest {
val aFeed = dvm("a".repeat(64))
val event =
FavoriteAlgoFeedsListEvent.create(
feed = aFeed,
isPrivate = false,
signer = signer,
createdAt = 1740669816,
)
assertEquals(10090, event.kind)
assertTrue(
event.tags.any { it.size >= 2 && it[0] == "a" && it[1] == aFeed.address.toValue() },
"public a tag for the favourited DVM should be present",
)
val favorites = event.publicFavoriteAlgoFeeds()
assertEquals(1, favorites.size)
assertEquals(aFeed.address, favorites.first().address)
}
@Test
fun addAppendsWithoutDuplicatingExistingEntry() =
runTest {
val aFeed = dvm("a".repeat(64))
val initial =
FavoriteAlgoFeedsListEvent.create(
feed = aFeed,
isPrivate = false,
signer = signer,
createdAt = 1740669816,
)
val afterDupeAdd =
FavoriteAlgoFeedsListEvent.add(
earlierVersion = initial,
feed = aFeed,
isPrivate = false,
signer = signer,
createdAt = 1740669817,
)
assertEquals(
1,
afterDupeAdd.publicFavoriteAlgoFeeds().count { it.address == aFeed.address },
"re-adding the same DVM must not produce a duplicate tag",
)
}
@Test
fun addPreservesOtherFavorites() =
runTest {
val first = dvm("a".repeat(64))
val second = dvm("b".repeat(64))
val initial =
FavoriteAlgoFeedsListEvent.create(
feed = first,
isPrivate = false,
signer = signer,
createdAt = 1740669816,
)
val after =
FavoriteAlgoFeedsListEvent.add(
earlierVersion = initial,
feed = second,
isPrivate = false,
signer = signer,
createdAt = 1740669817,
)
val addresses = after.publicFavoriteAlgoFeeds().map { it.address }.toSet()
assertTrue(first.address in addresses)
assertTrue(second.address in addresses)
}
@Test
fun removeDropsTheRequestedDvmOnly() =
runTest {
val first = dvm("a".repeat(64))
val second = dvm("b".repeat(64))
val initial =
FavoriteAlgoFeedsListEvent.create(
publicFeeds = listOf(first, second),
privateFeeds = emptyList(),
signer = signer,
createdAt = 1740669816,
)
val after =
FavoriteAlgoFeedsListEvent.remove(
earlierVersion = initial,
feed = first.address,
signer = signer,
createdAt = 1740669817,
)
val addresses = after.publicFavoriteAlgoFeeds().map { it.address }.toSet()
assertFalse(first.address in addresses, "removed DVM should not survive")
assertTrue(second.address in addresses, "other DVMs should be preserved")
}
}