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:
+192
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -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() })
|
||||
+28
@@ -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)
|
||||
|
||||
+159
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user