From 940e59b3234bb6415344b29d0a7465aace1efeea Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 03:49:21 +0000 Subject: [PATCH 01/15] feat: NIP-89 compliance fixes, modernize package structure, link NIP-88 polls Fix PlatformLinkTag.parse() off-by-one bug where tag indices were shifted (platform/uri/entityType read from wrong positions). Add missing TagArrayExt and EventExt files for definition and recommendation packages to align with the modern pattern used by nip88Polls. Parameterize DiscoverNIP89FeedFilter by targetKind and create NIP-89 poll app discovery infrastructure linking NIP-88 polls (kind 1068) to the NIP-89 app handler discovery system. https://claude.ai/code/session_013r2LXj11SieWa5PaLesKfC --- .../DiscoverNIP89PollAppsFeedFilter.kt | 47 +++++++++ .../nip88PollApps/SubAssemblyHelper.kt | 45 +++++++++ .../subassemblies/FilterPollAppsByAuthors.kt | 94 ++++++++++++++++++ .../subassemblies/FilterPollAppsByFollows.kt | 44 +++++++++ .../subassemblies/FilterPollAppsGlobal.kt | 52 ++++++++++ .../nip90DVMs/DiscoverNIP89FeedFilter.kt | 15 +-- .../nip89AppHandlers/definition/EventExt.kt | 23 +++++ .../definition/TagArrayExt.kt | 26 +++++ .../definition/tags/PlatformLinkTag.kt | 2 +- .../recommendation/TagArrayExt.kt | 28 ++++++ .../definition/PlatformLinkTagTest.kt | 97 +++++++++++++++++++ 11 files changed, 465 insertions(+), 8 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/DiscoverNIP89PollAppsFeedFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/SubAssemblyHelper.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/subassemblies/FilterPollAppsByAuthors.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/subassemblies/FilterPollAppsByFollows.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/subassemblies/FilterPollAppsGlobal.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/EventExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/TagArrayExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/TagArrayExt.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/PlatformLinkTagTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/DiscoverNIP89PollAppsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/DiscoverNIP89PollAppsFeedFilter.kt new file mode 100644 index 000000000..8ff75b3ec --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/DiscoverNIP89PollAppsFeedFilter.kt @@ -0,0 +1,47 @@ +/* + * 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.amethyst.ui.screen.loggedIn.discover.nip88PollApps + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.DiscoverNIP89FeedFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent + +/** + * NIP-89 discovery feed filter for poll-supporting applications. + * + * Discovers [AppDefinitionEvent]s that declare support for [PollEvent.KIND] (1068), + * linking NIP-88 polls to NIP-89 app handler discovery. + */ +class DiscoverNIP89PollAppsFeedFilter( + account: Account, +) : DiscoverNIP89FeedFilter(account, PollEvent.KIND) { + override fun acceptApp( + noteEvent: AppDefinitionEvent, + relays: List, + ): Boolean { + val filterParams = buildFilterParams(account) + return filterParams.match(noteEvent, relays) && + noteEvent.includeKind(targetKind) && + noteEvent.createdAt > lastAnnounced + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/SubAssemblyHelper.kt new file mode 100644 index 000000000..0f6c09efa --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/SubAssemblyHelper.kt @@ -0,0 +1,45 @@ +/* + * 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.amethyst.ui.screen.loggedIn.discover.nip88PollApps + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip88PollApps.subassemblies.filterPollAppsByAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip88PollApps.subassemblies.filterPollAppsByFollows +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip88PollApps.subassemblies.filterPollAppsGlobal +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun makePollAppsFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long?, +): List = + when (feedSettings) { + is AllFollowsTopNavPerRelayFilterSet -> filterPollAppsByFollows(feedSettings, since, defaultSince) + is AuthorsTopNavPerRelayFilterSet -> filterPollAppsByAuthors(feedSettings, since, defaultSince) + is GlobalTopNavPerRelayFilterSet -> filterPollAppsGlobal(feedSettings, since, defaultSince) + is MutedAuthorsTopNavPerRelayFilterSet -> filterPollAppsByAuthors(feedSettings, since, defaultSince) + else -> emptyList() + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/subassemblies/FilterPollAppsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/subassemblies/FilterPollAppsByAuthors.kt new file mode 100644 index 000000000..74b2bc553 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/subassemblies/FilterPollAppsByAuthors.kt @@ -0,0 +1,94 @@ +/* + * 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.amethyst.ui.screen.loggedIn.discover.nip88PollApps.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent + +fun filterPollAppsAuthors( + relay: NormalizedRelayUrl, + authors: Set, + since: Long? = null, +): List { + val authorList = authors.sorted() + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = listOf(AppDefinitionEvent.KIND), + tags = mapOf("k" to listOf(PollEvent.KIND.toString())), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterPollAppsByAuthors( + authorSet: AuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterPollAppsAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} + +fun filterPollAppsByAuthors( + authorSet: MutedAuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterPollAppsAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/subassemblies/FilterPollAppsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/subassemblies/FilterPollAppsByFollows.kt new file mode 100644 index 000000000..ddd49e3db --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/subassemblies/FilterPollAppsByFollows.kt @@ -0,0 +1,44 @@ +/* + * 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.amethyst.ui.screen.loggedIn.discover.nip88PollApps.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun filterPollAppsByFollows( + followsSet: AllFollowsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (followsSet.set.isEmpty()) return emptyList() + + return followsSet.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + val relay = it.key + + listOfNotNull( + it.value.authors?.let { + filterPollAppsAuthors(relay, it, since) + }, + ).flatten() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/subassemblies/FilterPollAppsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/subassemblies/FilterPollAppsGlobal.kt new file mode 100644 index 000000000..e31572d83 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/subassemblies/FilterPollAppsGlobal.kt @@ -0,0 +1,52 @@ +/* + * 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.amethyst.ui.screen.loggedIn.discover.nip88PollApps.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent + +fun filterPollAppsGlobal( + relays: GlobalTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (relays.set.isEmpty()) return emptyList() + + return relays.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + listOf( + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = listOf(AppDefinitionEvent.KIND), + tags = mapOf("k" to listOf(PollEvent.KIND.toString())), + limit = 30, + since = since, + ), + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DiscoverNIP89FeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DiscoverNIP89FeedFilter.kt index 74078dda7..7fcfb4bf6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DiscoverNIP89FeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DiscoverNIP89FeedFilter.kt @@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils open class DiscoverNIP89FeedFilter( val account: Account, + val targetKind: Int = 5300, ) : AdditiveFeedFilter() { val lastAnnounced = TimeUtils.oneYearAgo() @@ -61,7 +62,7 @@ open class DiscoverNIP89FeedFilter( override fun feed(): List { val notes = LocalCache.addressables.filterIntoSet(AppDefinitionEvent.KIND) { _, it -> - acceptDVM(it) + acceptApp(it) } return sort(notes) @@ -75,29 +76,29 @@ open class DiscoverNIP89FeedFilter( account.hiddenUsers.flow.value, ) - fun acceptDVM(note: Note): Boolean { + fun acceptApp(note: Note): Boolean { val noteEvent = note.event return if (noteEvent is AppDefinitionEvent) { - acceptDVM(noteEvent, note.relays) + acceptApp(noteEvent, note.relays) } else { false } } - fun acceptDVM( + open fun acceptApp( noteEvent: AppDefinitionEvent, relays: List, ): Boolean { val filterParams = buildFilterParams(account) return noteEvent.appMetaData()?.subscription != true && filterParams.match(noteEvent, relays) && - noteEvent.includeKind(5300) && - noteEvent.createdAt > lastAnnounced // && params.match(noteEvent) + noteEvent.includeKind(targetKind) && + noteEvent.createdAt > lastAnnounced } protected open fun innerApplyFilter(collection: Collection): Set = collection.filterTo(HashSet()) { - acceptDVM(it) + acceptApp(it) } override fun sort(items: Set): List { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/EventExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/EventExt.kt new file mode 100644 index 000000000..6b6505ba6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/EventExt.kt @@ -0,0 +1,23 @@ +/* + * 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.nip89AppHandlers.definition + +fun AppDefinitionEvent.platformLinks() = tags.platformLinks() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/TagArrayExt.kt new file mode 100644 index 000000000..87818b5e7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/TagArrayExt.kt @@ -0,0 +1,26 @@ +/* + * 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.nip89AppHandlers.definition + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip89AppHandlers.definition.tags.PlatformLinkTag + +fun TagArray.platformLinks() = this.mapNotNull(PlatformLinkTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/tags/PlatformLinkTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/tags/PlatformLinkTag.kt index 513f3b2ff..b6a8ba7b7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/tags/PlatformLinkTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/tags/PlatformLinkTag.kt @@ -41,7 +41,7 @@ class PlatformLinkTag( } fun parse(tag: Tag): PlatformLinkTag? { - if (match(tag)) return PlatformLinkTag(tag[1], tag[2], tag.getOrNull(3)) + if (match(tag)) return PlatformLinkTag(tag[0], tag[1], tag.getOrNull(2)) return null } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/TagArrayExt.kt new file mode 100644 index 000000000..1610ece8e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/TagArrayExt.kt @@ -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.nip89AppHandlers.recommendation + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.tags.RecommendationTag + +fun TagArray.recommendations() = this.mapNotNull(RecommendationTag::parse) + +fun TagArray.recommendationAddresses() = this.mapNotNull(RecommendationTag::parseAddressId) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/PlatformLinkTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/PlatformLinkTagTest.kt new file mode 100644 index 000000000..27b5e1f54 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/PlatformLinkTagTest.kt @@ -0,0 +1,97 @@ +/* + * 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.nip89AppHandlers.definition + +import com.vitorpamplona.quartz.nip89AppHandlers.definition.tags.PlatformLinkTag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class PlatformLinkTagTest { + @Test + fun parsesWebPlatformLinkWithEntityType() { + val tag = arrayOf("web", "https://example.com/a/", "nevent") + val result = PlatformLinkTag.parse(tag) + + assertNotNull(result) + assertEquals("web", result.platform) + assertEquals("https://example.com/a/", result.uri) + assertEquals("nevent", result.entityType) + } + + @Test + fun parsesAndroidPlatformLink() { + val tag = arrayOf("android", "amethyst://note/", "note") + val result = PlatformLinkTag.parse(tag) + + assertNotNull(result) + assertEquals("android", result.platform) + assertEquals("amethyst://note/", result.uri) + assertEquals("note", result.entityType) + } + + @Test + fun parsesIosPlatformLink() { + val tag = arrayOf("ios", "damus://note/", "naddr") + val result = PlatformLinkTag.parse(tag) + + assertNotNull(result) + assertEquals("ios", result.platform) + assertEquals("damus://note/", result.uri) + assertEquals("naddr", result.entityType) + } + + @Test + fun rejectsTagWithOnlyOneElement() { + val tag = arrayOf("web") + assertNull(PlatformLinkTag.parse(tag)) + } + + @Test + fun rejectsUnknownPlatform() { + val tag = arrayOf("windows", "https://example.com", "note") + assertNull(PlatformLinkTag.parse(tag)) + } + + @Test + fun roundTripPreservesValues() { + val original = PlatformLinkTag("web", "https://example.com/e/", "nevent") + val tagArray = original.toTagArray() + val parsed = PlatformLinkTag.parse(tagArray) + + assertNotNull(parsed) + assertEquals(original.platform, parsed.platform) + assertEquals(original.uri, parsed.uri) + assertEquals(original.entityType, parsed.entityType) + } + + @Test + fun roundTripWithoutEntityType() { + val original = PlatformLinkTag("android", "amethyst://open/", null) + val tagArray = original.toTagArray() + val parsed = PlatformLinkTag.parse(tagArray) + + // Without entityType, tag only has 2 elements, so match requires has(2) which means 3 elements + // This is expected behavior per NIP-89 spec (entityType is specified) + assertNull(parsed) + } +} From 4ba92931f63c40ff887a98bd295df14e198189c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 04:13:53 +0000 Subject: [PATCH 02/15] docs: add relay README for building with Ktor, NostrServer, and SQLite EventStore Comprehensive guide covering the full relay stack: NostrServer setup, SQLite EventStore configuration, Ktor WebSocket integration, policy system (VerifyPolicy, FullAuthPolicy, PolicyStack), indexing strategies, testing patterns, and NIP support matrix. https://claude.ai/code/session_01CTyiKDNhgXdfCFBBBXf1NF --- quartz/RELAY.md | 505 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 505 insertions(+) create mode 100644 quartz/RELAY.md diff --git a/quartz/RELAY.md b/quartz/RELAY.md new file mode 100644 index 000000000..88233d26f --- /dev/null +++ b/quartz/RELAY.md @@ -0,0 +1,505 @@ +# Building a Nostr Relay with Quartz + +This guide walks you through building a fully functional Nostr relay using Quartz's `NostrServer`, the SQLite `EventStore`, and [Ktor](https://ktor.io/) as the WebSocket transport layer. + +## Overview + +Quartz provides a complete, transport-agnostic relay engine: + +| Component | Class | Role | +|-----------|-------|------| +| **NostrServer** | `com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer` | Coordinates connections, sessions, and event routing | +| **EventStore** | `com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore` | SQLite-backed persistent event storage | +| **RelaySession** | `com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession` | Manages a single client connection and its subscriptions | +| **IRelayPolicy** | `com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy` | Pluggable access control and validation | + +`NostrServer` doesn't know about HTTP or WebSockets. You provide a `send` callback per connection, and it gives you a `RelaySession` that accepts raw JSON strings. This makes it trivial to plug into Ktor (or any other transport). + +## Quick Start + +### 1. Add Dependencies + +In your `build.gradle.kts`: + +```kotlin +dependencies { + implementation("io.ktor:ktor-server-core:3.1.1") + implementation("io.ktor:ktor-server-netty:3.1.1") + implementation("io.ktor:ktor-server-websockets:3.1.1") + + // Quartz (use your local module or published artifact) + implementation(project(":quartz")) +} +``` + +### 2. Create the Relay Server + +```kotlin +import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import io.ktor.server.application.* +import io.ktor.server.engine.* +import io.ktor.server.netty.* +import io.ktor.server.routing.* +import io.ktor.server.websocket.* +import io.ktor.websocket.* + +fun main() { + // 1. Create the event store (SQLite, persisted to disk) + val store = EventStore( + dbName = "relay-events.db", + ) + + // 2. Create the NostrServer with the default VerifyPolicy + val server = NostrServer(store) + + // 3. Start Ktor with WebSocket support + embeddedServer(Netty, port = 7777) { + install(WebSockets) + + routing { + webSocket("/") { + // 4. Register this WebSocket as a relay connection + val session = server.connect { json -> + launch { send(Frame.Text(json)) } + } + + try { + // 5. Forward incoming frames to the relay session + for (frame in incoming) { + if (frame is Frame.Text) { + session.receive(frame.readText()) + } + } + } finally { + // 6. Clean up when the client disconnects + session.close() + } + } + } + }.start(wait = true) +} +``` + +That's it. You now have a NIP-01 compliant relay running on `ws://localhost:7777`. + +## How It Works + +### Connection Lifecycle + +``` +Client connects via WebSocket + │ + ▼ +server.connect { json -> send(json) } + │ + ▼ + RelaySession created + (policy.onConnect called, e.g. AUTH challenge sent) + │ + ▼ +┌───────────────────────────────┐ +│ For each incoming text frame │ +│ session.receive(jsonString) │──► Parses NIP-01 command +│ │──► Validates via policy +│ │──► Dispatches to handler +└───────────────────────────────┘ + │ + ▼ +Client disconnects → session.close() + │ + ▼ + All subscriptions cancelled +``` + +### Supported NIP-01 Commands + +| Client Command | Handler | Description | +|----------------|---------|-------------| +| `["EVENT", ]` | `handleEvent` | Publishes an event. Policy validates, store persists, `OK` response sent. | +| `["REQ", , ...]` | `handleReq` | Opens a subscription. Returns matching historical events, `EOSE`, then streams live matches. | +| `["CLOSE", ]` | `handleClose` | Cancels a subscription. | +| `["COUNT", , ...]` | `handleCount` | Returns the count of matching events (NIP-45). | +| `["AUTH", ]` | `handleAuth` | Authenticates the client (NIP-42). | + +### Live Subscriptions + +When a client sends `REQ`, the relay: +1. Queries the `EventStore` for all matching historical events +2. Sends each as an `EVENT` message +3. Sends `EOSE` (End of Stored Events) +4. Keeps the subscription open - any new event inserted by *any* client that matches the filter is automatically pushed + +This is handled by `LiveEventStore`, which wraps the store with a `SharedFlow` that broadcasts new events to all active subscriptions. + +## Event Store + +### SQLite (Persistent) + +```kotlin +val store = EventStore( + dbName = "relay-events.db", +) +``` + +The SQLite store uses `androidx.sqlite` with the bundled driver (no native SQLite dependency needed). It features: + +- **WAL journal mode** for concurrent reads/writes +- **32 MB memory cache** for fast queries +- **Modular architecture** with pluggable processing modules: + +| Module | NIP | Purpose | +|--------|-----|---------| +| `SeedModule` | NIP-01 | Core event storage and retrieval | +| `EventIndexesModule` | NIP-01 | Indexes for efficient filtering | +| `ReplaceableModule` | NIP-16 | Replaces older versions of replaceable events | +| `AddressableModule` | NIP-33 | Handles parameterized replaceable events | +| `EphemeralModule` | NIP-01 | Rejects ephemeral events from persistence | +| `DeletionRequestModule` | NIP-09 | Processes deletion requests | +| `ExpirationModule` | NIP-40 | Handles event expiration timestamps | +| `RightToVanishModule` | — | Cleans up ephemeral data | +| `FullTextSearchModule` | NIP-50 | Full-text search on event content | + +### In-Memory (Testing) + +Pass `null` as the database name for a purely in-memory store (no disk I/O): + +```kotlin +val store = EventStore(null) +``` + +### Indexing Strategy + +Control which indexes are created via `IndexingStrategy`: + +```kotlin +val store = EventStore( + dbName = "relay-events.db", + indexStrategy = DefaultIndexingStrategy( + // Enable if you receive many filter-by-time-only queries + indexEventsByCreatedAtAlone = false, + // Enable if you receive many tag-only queries without kind + indexTagsByCreatedAtAlone = false, + // Enable for queries combining tags + kind + author + indexTagsWithKindAndPubkey = false, + // Enable for spec-compliant ordering when created_at collides + useAndIndexIdOnOrderBy = false, + ), +) +``` + +By default, all single-letter tags with values are indexed (e.g., `#e`, `#p`, `#t`). Override `shouldIndex(kind, tag)` for custom behavior. + +> **Tip:** More indexes = faster queries but larger database. Only enable what your relay's query patterns actually need. + +## Policies + +Policies control what clients can do. They validate commands and can rewrite filters before execution. + +### Built-in Policies + +#### `VerifyPolicy` (default) + +Verifies event signatures and IDs. Rejects malformed events. Allows all `REQ` and `COUNT` commands. + +```kotlin +val server = NostrServer(store) // Uses VerifyPolicy by default +``` + +#### `EmptyPolicy` + +Accepts everything without any validation. Useful for testing or trusted environments. + +```kotlin +val server = NostrServer(store, policyBuilder = { EmptyPolicy }) +``` + +#### `FullAuthPolicy` + +Requires NIP-42 authentication before accepting any `EVENT`, `REQ`, or `COUNT` commands. Sends an `AUTH` challenge on connect. + +```kotlin +val server = NostrServer( + store = store, + policyBuilder = { + FullAuthPolicy(relay = "wss://myrelay.example.com/".normalizeRelayUrl()!!) + }, +) +``` + +Validates: +- Challenge string matches +- Relay URL matches +- Timestamp is within 10 minutes +- Event signature is valid + +### Composing Policies with `PolicyStack` + +Chain multiple policies together. All must approve; the first rejection wins. Policies run in order and can rewrite commands for downstream policies. + +```kotlin +val server = NostrServer( + store = store, + policyBuilder = { + PolicyStack( + VerifyPolicy, + FullAuthPolicy(relay = "wss://myrelay.example.com/".normalizeRelayUrl()!!), + MyCustomRateLimitPolicy(), + ) + }, +) +``` + +### Writing a Custom Policy + +Implement `IRelayPolicy`: + +```kotlin +import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult + +class KindWhitelistPolicy( + private val allowedKinds: Set, +) : IRelayPolicy { + + override fun onConnect(send: (Message) -> Unit) { } + + override fun accept(cmd: EventCmd): PolicyResult = + if (cmd.event.kind in allowedKinds) { + PolicyResult.Accepted(cmd) + } else { + PolicyResult.Rejected("blocked: kind ${cmd.event.kind} not allowed") + } + + override fun accept(cmd: ReqCmd) = PolicyResult.Accepted(cmd) + override fun accept(cmd: CountCmd) = PolicyResult.Accepted(cmd) + override fun accept(cmd: AuthCmd) = PolicyResult.Accepted(cmd) +} +``` + +Use it: + +```kotlin +val server = NostrServer( + store = store, + policyBuilder = { + PolicyStack( + VerifyPolicy, + KindWhitelistPolicy(allowedKinds = setOf(0, 1, 3, 7, 30023)), + ) + }, +) +``` + +**Policy interface methods:** + +| Method | When Called | Return | +|--------|------------|--------| +| `onConnect(send)` | New client connects | Send welcome messages (e.g., AUTH challenge) | +| `accept(EventCmd)` | Client publishes an event | `Accepted` or `Rejected("reason")` | +| `accept(ReqCmd)` | Client opens a subscription | `Accepted` (may rewrite filters) or `Rejected` | +| `accept(CountCmd)` | Client requests a count | `Accepted` or `Rejected` | +| `accept(AuthCmd)` | Client authenticates | `Accepted` or `Rejected` | +| `canSendToSession(event)` | Live event about to be forwarded | `true` to deliver, `false` to suppress | + +## Full Example: Production-Ready Relay + +```kotlin +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyStack +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy +import com.vitorpamplona.quartz.nip01Core.store.sqlite.DefaultIndexingStrategy +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import io.ktor.server.application.* +import io.ktor.server.engine.* +import io.ktor.server.netty.* +import io.ktor.server.routing.* +import io.ktor.server.websocket.* +import io.ktor.websocket.* +import kotlinx.coroutines.launch +import java.time.Duration + +fun main() { + val relayUrl = "wss://myrelay.example.com/" + + val store = EventStore( + dbName = "relay-events.db", + relay = relayUrl.normalizeRelayUrl(), + indexStrategy = DefaultIndexingStrategy( + indexEventsByCreatedAtAlone = true, + ), + ) + + val server = NostrServer( + store = store, + policyBuilder = { + PolicyStack( + VerifyPolicy, + // Add your custom policies here + ) + }, + ) + + embeddedServer(Netty, port = 7777) { + install(WebSockets) { + pingPeriodMillis = 30_000 + timeoutMillis = 60_000 + maxFrameSize = Long.MAX_VALUE + } + + routing { + webSocket("/") { + val session = server.connect { json -> + launch { send(Frame.Text(json)) } + } + + try { + for (frame in incoming) { + when (frame) { + is Frame.Text -> session.receive(frame.readText()) + else -> { /* ignore binary, ping, pong */ } + } + } + } finally { + session.close() + } + } + } + }.start(wait = true) + + // Clean shutdown + Runtime.getRuntime().addShutdownHook(Thread { + server.shutdown() + store.close() + }) +} +``` + +## Architecture Diagram + +``` +┌─────────────────────────────────────────────────┐ +│ Ktor Server │ +│ │ +│ WebSocket("/") │ +│ │ │ +│ ▼ │ +│ ┌────────────────────────────────────────┐ │ +│ │ NostrServer │ │ +│ │ │ │ +│ │ connect(send) ──► RelaySession │ │ +│ │ │ │ │ +│ │ ├─ IRelayPolicy │ │ +│ │ │ (validate) │ │ +│ │ │ │ │ +│ │ ├─ LiveEventStore │ │ +│ │ │ (query + live) │ │ +│ │ │ │ │ +│ │ └─ Subscriptions │ │ +│ │ (per client) │ │ +│ └────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌────────────────────────────────────────┐ │ +│ │ EventStore (SQLite) │ │ +│ │ │ │ +│ │ insert / query / count / delete │ │ +│ │ │ │ +│ │ Modules: │ │ +│ │ Seed ─ Replaceable ─ Addressable │ │ +│ │ Deletion ─ Expiration ─ FTS │ │ +│ └────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────┘ +``` + +## Testing + +Use the in-memory store and `UnconfinedTestDispatcher` for deterministic tests: + +```kotlin +import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class MyRelayTest { + + @Test + fun clientCanPublishAndSubscribe() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) // in-memory + val server = NostrServer( + store = store, + policyBuilder = { EmptyPolicy }, + parentContext = dispatcher, + ) + + // Collect messages sent to this client + val messages = mutableListOf() + val session = server.connect { messages.add(it) } + + // Publish an event + session.receive("""["EVENT",{"id":"${"0".repeat(64)}","pubkey":"${"a".repeat(64)}","created_at":1000,"kind":1,"tags":[],"content":"hello","sig":"${"b".repeat(128)}"}]""") + + // Verify OK response + assertTrue(messages.any { it.contains("OK") }) + + // Subscribe to kind 1 + session.receive("""["REQ","sub1",{"kinds":[1]}]""") + + // Verify we get the event back + EOSE + assertTrue(messages.any { it.contains("EVENT") }) + assertTrue(messages.any { it.contains("EOSE") }) + + server.shutdown() + } +} +``` + +## NIP Support + +The relay engine and SQLite store support the following NIPs out of the box: + +| NIP | Feature | Component | +|-----|---------|-----------| +| NIP-01 | Basic protocol (EVENT, REQ, CLOSE, EOSE, OK, NOTICE) | `NostrServer`, `RelaySession` | +| NIP-09 | Event deletion | `DeletionRequestModule` | +| NIP-16 | Replaceable events | `ReplaceableModule` | +| NIP-33 | Parameterized replaceable events | `AddressableModule` | +| NIP-40 | Expiration timestamp | `ExpirationModule` | +| NIP-42 | Authentication | `FullAuthPolicy` | +| NIP-45 | Event counts | `RelaySession.handleCount` | +| NIP-50 | Search | `FullTextSearchModule` | + +## Key Source Files + +All relay infrastructure lives in the `quartz` module: + +``` +quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/ +├── relay/server/ +│ ├── NostrServer.kt # Main entry point +│ ├── RelaySession.kt # Per-connection handler +│ ├── LiveEventStore.kt # Reactive event streaming +│ ├── IRelayPolicy.kt # Policy interface + PolicyResult +│ └── policies/ +│ ├── EmptyPolicy.kt # Accept everything +│ ├── VerifyPolicy.kt # Signature verification (default) +│ ├── FullAuthPolicy.kt # NIP-42 auth required +│ └── PolicyStack.kt # Chain multiple policies +├── store/ +│ ├── IEventStore.kt # Storage interface +│ └── sqlite/ +│ ├── EventStore.kt # Public SQLite store wrapper +│ ├── SQLiteEventStore.kt # Full implementation +│ └── IndexingStrategy.kt # Index configuration +└── relay/filters/ + ├── Filter.kt # NIP-01 subscription filters + └── FilterMatcher.kt # Event-to-filter matching +``` From 0e8c6fa4343038cd80f3c9b420aeeb4cd351cc88 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 26 Mar 2026 09:05:37 +0200 Subject: [PATCH 03/15] fix(cache): route ReadsScreen following-mode events through cache Following-mode long-form feed was not calling consumeEvent(), so LongTextNoteEvents never reached the cache. Back-navigation showed empty reads because cache had nothing to seed from. --- .../com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt index 80438b7b0..42f3ad82a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt @@ -204,7 +204,8 @@ fun ReadsScreen( val events by eventState.items.collectAsState() var feedMode by remember { mutableStateOf(FeedMode.GLOBAL) } - var followedUsers by remember { mutableStateOf>(emptySet()) } + // Seed followedUsers from cache so Following mode works immediately on back-nav + var followedUsers by remember { mutableStateOf(localCache.followedUsers.value) } var eoseReceivedCount by remember { mutableStateOf(0) } val initialLoadComplete = eoseReceivedCount > 0 @@ -272,7 +273,8 @@ fun ReadsScreen( createFollowingLongFormFeedSubscription( relays = connectedRelays, followedUsers = followedUsers.toList(), - onEvent = { event, _, _, _ -> + onEvent = { event, _, relay, _ -> + subscriptionsCoordinator?.consumeEvent(event, relay) if (event is LongTextNoteEvent) { eventState.addItem(event) } From 2b3005797e20d1fbcc25bf967202ab3dcfb31913 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 30 Mar 2026 11:29:02 +0300 Subject: [PATCH 04/15] feat(desktop): render reposts and quoted notes in feed - Extract GenericRepostLayout + BoostedMark to commons for cross-platform use - Feed filters accept kind 6/16 reposts with deduplication by original note - Relay subscriptions request kinds 1, 6, 16 - Cache consumes GenericRepostEvent (kind 16) and uses getOrCreateNote for repost originals to handle out-of-order arrival - FeedNoteCard renders reposts with overlapping avatars + "Boosted" label - QuotedNoteEmbed renders nostr:nevent/note references as embedded NoteCards with reactive metadata observation - Direct relay subscriptions fetch missing referenced notes and author metadata - FeedMetadataCoordinator routes all events (not just metadata) back to cache - consumeMetadata invalidates note flows when author metadata arrives Co-Authored-By: Claude Opus 4.6 (1M context) --- .../commons/compose/elements/BoostedMark.kt | 40 +++ .../compose/layouts/GenericRepostLayout.kt | 74 +++++ .../assemblers/FeedMetadataCoordinator.kt | 35 +++ .../desktop/cache/DesktopLocalCache.kt | 36 ++- .../desktop/feeds/DesktopFeedFilters.kt | 32 +- .../DesktopRelaySubscriptionsCoordinator.kt | 8 +- .../desktop/subscriptions/FilterBuilders.kt | 6 +- .../amethyst/desktop/ui/FeedScreen.kt | 283 +++++++++++++++--- .../amethyst/desktop/ui/note/NoteCard.kt | 251 +++++++++++----- .../desktop/filters/FilterBuildersTest.kt | 14 +- 10 files changed, 644 insertions(+), 135 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/elements/BoostedMark.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/layouts/GenericRepostLayout.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/elements/BoostedMark.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/elements/BoostedMark.kt new file mode 100644 index 000000000..173639ccd --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/elements/BoostedMark.kt @@ -0,0 +1,40 @@ +/* + * 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.amethyst.commons.compose.elements + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +@Composable +fun BoostedMark() { + Text( + "Boosted", + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + modifier = Modifier.padding(start = 5.dp), + ) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/layouts/GenericRepostLayout.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/layouts/GenericRepostLayout.kt new file mode 100644 index 000000000..a77f979ef --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/layouts/GenericRepostLayout.kt @@ -0,0 +1,74 @@ +/* + * 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.amethyst.commons.compose.layouts + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.Repost + +private val Size55Modifier = Modifier.size(55.dp) +private val Size35Modifier = Modifier.size(35.dp) +private val Size18Modifier = Modifier.size(18.dp) + +@Composable +fun RepostIcon( + modifier: Modifier, + tint: Color = Color.Unspecified, +) { + Icon( + imageVector = Repost, + contentDescription = "Boost or quote", + modifier = modifier, + tint = tint, + ) +} + +@Composable +fun GenericRepostLayout( + baseAuthorPicture: @Composable () -> Unit, + repostAuthorPicture: @Composable () -> Unit, +) { + Box(modifier = Size55Modifier) { + Box(remember { Size35Modifier.align(Alignment.TopStart) }) { baseAuthorPicture() } + + Box( + remember { Size18Modifier.align(Alignment.BottomStart).padding(1.dp) }, + ) { + RepostIcon(modifier = Size18Modifier, MaterialTheme.colorScheme.onSurfaceVariant) + } + + Box( + remember { Size35Modifier.align(Alignment.BottomEnd) }, + contentAlignment = Alignment.BottomEnd, + ) { + repostAuthorPicture() + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt index 4b62fc9ab..aa03e9230 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt @@ -32,6 +32,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import kotlinx.coroutines.CoroutineScope @@ -66,6 +69,7 @@ class FeedMetadataCoordinator( // Track what we've already queued to avoid duplicates private val queuedPubkeys = mutableSetOf() private val queuedNoteIds = mutableSetOf() + private val queuedBoostedIds = mutableSetOf() /** * Start processing the subscription queue. @@ -110,6 +114,37 @@ class FeedMetadataCoordinator( fun loadMetadataForNotes(notes: List) { if (notes.isEmpty()) return + // Fetch referenced note content: reposts (via replyTo) + quoted notes (via e-tags) + val repostBoostedIds = + notes + .filter { it.event is RepostEvent || it.event is GenericRepostEvent } + .mapNotNull { it.replyTo?.lastOrNull() } + .filter { it.event == null } + .map { it.idHex } + + val quotedNoteIds = + notes + .mapNotNull { it.event } + .flatMap { event -> event.tags.mapNotNull { ETag.parseId(it) } } + + val allReferencedIds = + (repostBoostedIds + quotedNoteIds) + .filter { it !in queuedBoostedIds } + .distinct() + + if (allReferencedIds.isNotEmpty()) { + queuedBoostedIds.addAll(allReferencedIds) + val referencedFilter = + Filter( + ids = allReferencedIds, + ) + priorityQueue.enqueue( + SubscriptionPriority.METADATA, + referencedFilter, + tag = "feed-referenced-notes", + ) + } + // Extract unique authors that we haven't already queued val authors = notes diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index 319ec94db..140fecc46 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent @@ -148,6 +149,13 @@ class DesktopLocalCache : ICacheProvider { val newUserMetadata = event.contactMetaData() if (newUserMetadata != null) { user.updateUserInfo(newUserMetadata, event) + // Invalidate metadata flows on notes by this author that have observers + // so QuotedNoteEmbed/FeedNoteCard recompose with updated avatar/name + notes.forEach { _, note -> + if (note.author?.pubkeyHex == event.pubKey && note.flowSet?.metadata?.hasObservers() == true) { + note.flowSet?.metadata?.invalidateData() + } + } } } } @@ -188,6 +196,10 @@ class DesktopLocalCache : ICacheProvider { consumeRepost(event, relay) } + is GenericRepostEvent -> { + consumeGenericRepost(event, relay) + } + is ContactListEvent -> { consumeContactList(event) } @@ -299,6 +311,8 @@ class DesktopLocalCache : ICacheProvider { /** * Consumes a kind 6 repost event. * Links repost to target note via e-tag. + * Uses getOrCreateNote for the boosted note so the link exists even if + * the original note hasn't arrived yet (it will be filled in later). */ private fun consumeRepost( event: RepostEvent, @@ -307,7 +321,27 @@ class DesktopLocalCache : ICacheProvider { val note = getOrCreateNote(event.id) if (note.event != null) return false val author = getOrCreateUser(event.pubKey) - val boostedNote = event.boostedEventId()?.let { getNoteIfExists(it) } + val boostedId = event.boostedEventId() + val boostedNote = boostedId?.let { getOrCreateNote(it) } + val repliesTo = listOfNotNull(boostedNote) + note.loadEvent(event, author, repliesTo) + relay?.let { note.addRelay(it) } + boostedNote?.addBoost(note) + return true + } + + /** + * Consumes a kind 16 generic repost event. + * Links repost to target note via e-tag. + */ + private fun consumeGenericRepost( + event: GenericRepostEvent, + relay: NormalizedRelayUrl?, + ): Boolean { + val note = getOrCreateNote(event.id) + if (note.event != null) return false + val author = getOrCreateUser(event.pubKey) + val boostedNote = event.boostedEventId()?.let { getOrCreateNote(it) } val repliesTo = listOfNotNull(boostedNote) note.loadEvent(event, author, repliesTo) relay?.let { note.addRelay(it) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt index 8f87d901a..6aac787c9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt @@ -28,12 +28,26 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +private fun isFeedNote(event: com.vitorpamplona.quartz.nip01Core.core.Event?): Boolean = event is TextNoteEvent || event is RepostEvent || event is GenericRepostEvent + +private fun List.deduplicateReposts(): List = + distinctBy { note -> + val event = note.event + if (event is RepostEvent || event is GenericRepostEvent) { + note.replyTo?.lastOrNull()?.idHex ?: note.idHex + } else { + note.idHex + } + } + /** - * Global feed: all kind 1 text notes, sorted by createdAt desc. + * Global feed: kind 1 text notes + kind 6/16 reposts, sorted by createdAt desc. */ class DesktopGlobalFeedFilter( private val cache: DesktopLocalCache, @@ -42,19 +56,20 @@ class DesktopGlobalFeedFilter( override fun feed(): List = cache.notes - .filterIntoSet { _, note -> note.event is TextNoteEvent } + .filterIntoSet { _, note -> isFeedNote(note.event) } .sortedWith(DefaultFeedOrder) + .deduplicateReposts() .take(limit()) - override fun applyFilter(newItems: Set): Set = newItems.filterTo(HashSet()) { it.event is TextNoteEvent } + override fun applyFilter(newItems: Set): Set = newItems.filterTo(HashSet()) { isFeedNote(it.event) } - override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder).deduplicateReposts() override fun limit(): Int = 2500 } /** - * Following feed: kind 1 text notes from followed pubkeys. + * Following feed: kind 1 text notes + kind 6/16 reposts from followed pubkeys. */ class DesktopFollowingFeedFilter( private val cache: DesktopLocalCache, @@ -66,19 +81,20 @@ class DesktopFollowingFeedFilter( val follows = followedPubkeys() return cache.notes .filterIntoSet { _, note -> - note.event is TextNoteEvent && note.author?.pubkeyHex in follows + isFeedNote(note.event) && note.author?.pubkeyHex in follows }.sortedWith(DefaultFeedOrder) + .deduplicateReposts() .take(limit()) } override fun applyFilter(newItems: Set): Set { val follows = followedPubkeys() return newItems.filterTo(HashSet()) { - it.event is TextNoteEvent && it.author?.pubkeyHex in follows + isFeedNote(it.event) && it.author?.pubkeyHex in follows } } - override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder).deduplicateReposts() override fun limit(): Int = 2500 } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt index 474f3a163..9f1116cfe 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt @@ -91,11 +91,9 @@ class DesktopRelaySubscriptionsCoordinator( scope = scope, indexRelays = indexRelays, preloader = preloader, - onEvent = { event, _ -> - // Consume metadata events into local cache - if (event is MetadataEvent) { - localCache.consumeMetadata(event) - } + onEvent = { event, relay -> + // Route all fetched events (metadata, boosted notes, etc.) into cache + localCache.consume(event, relay) }, ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt index fee6d8c3b..ecdefb508 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt @@ -27,6 +27,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter * Provides convenience functions for creating relay subscription filters. */ object FilterBuilders { + private val FEED_KINDS = listOf(1, 6, 16) // TextNoteEvent, RepostEvent, GenericRepostEvent + /** * Creates a filter for text notes (kind 1) from all authors. * @@ -41,7 +43,7 @@ object FilterBuilders { until: Long? = null, ): Filter = Filter( - kinds = listOf(1), // TextNoteEvent.KIND + kinds = FEED_KINDS, limit = limit, since = since, until = until, @@ -63,7 +65,7 @@ object FilterBuilders { until: Long? = null, ): Filter = Filter( - kinds = listOf(1), // TextNoteEvent.KIND + kinds = FEED_KINDS, authors = authors, limit = limit, since = since, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 654da5d7c..23dc88b35 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -55,9 +55,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.compose.elements.BoostedMark +import com.vitorpamplona.amethyst.commons.compose.layouts.GenericRepostLayout import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.richtext.UrlParser import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState +import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.desktop.DesktopPreferences import com.vitorpamplona.amethyst.desktop.account.AccountState @@ -67,14 +71,22 @@ import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode +import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders +import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createFollowingFeedSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createGlobalFeedSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote data class LightboxState( val urls: List, @@ -86,6 +98,8 @@ data class LightboxState( /** * Note card that reads counts from the Note model (cache-backed). * Event is extracted from Note for signing operations in NoteActionsRow. + * Handles reposts (kind 6/16) by showing overlapping avatars + "Boosted" label + * and rendering the original note content. */ @Composable fun FeedNoteCard( @@ -102,53 +116,141 @@ fun FeedNoteCard( onMediaClick: ((List, Int, Float) -> Unit)? = null, ) { val event = note.event ?: return + val isRepost = event is RepostEvent || event is GenericRepostEvent - // Observe Note.flowSet for live count updates - val flowSet = remember(note) { note.flow() } - val reactionsState by flowSet.reactions.stateFlow.collectAsState() - val repliesState by flowSet.replies.stateFlow.collectAsState() - val zapsState by flowSet.zaps.stateFlow.collectAsState() + if (isRepost) { + val originalNote = note.replyTo?.lastOrNull() + if (originalNote == null) { + return + } - // Read counts from Note model (re-read on each stateFlow emission) - val reactionCount = note.countReactions() - val replyCount = note.replies.size - val repostCount = note.boosts.size - val zapAmount = note.zapsAmount + // Observe original note's flowSet — MUST happen before reading .event + // so we recompose when the async fetch fills in the event + val flowSet = remember(originalNote) { originalNote.flow() } + val metadataState by flowSet.metadata.stateFlow.collectAsState() + val reactionsState by flowSet.reactions.stateFlow.collectAsState() + val repliesState by flowSet.replies.stateFlow.collectAsState() + val zapsState by flowSet.zaps.stateFlow.collectAsState() - // Clean up flowSet when card leaves composition - DisposableEffect(note) { - onDispose { note.clearFlow() } - } + DisposableEffect(originalNote) { + onDispose { originalNote.clearFlow() } + } - Column { - NoteCard( - note = event.toNoteDisplayData(localCache), - localCache = localCache, - onClick = { onNavigateToThread(event.id) }, - onAuthorClick = onNavigateToProfile, - onMentionClick = onNavigateToProfile, - onImageClick = onImageClick, - onMediaClick = onMediaClick, - ) + // Now read event — recomposition will re-read this when metadata invalidates + val originalEvent = originalNote.event + if (originalEvent == null) { + return + } - // Action buttons (only if logged in) - if (account != null) { - NoteActionsRow( - event = event, - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - onReplyClick = onReply, - onZapFeedback = onZapFeedback, + val reactionCount = originalNote.countReactions() + val replyCount = originalNote.replies.size + val repostCount = originalNote.boosts.size + val zapAmount = originalNote.zapsAmount + + val reposterUser = localCache.getUserIfExists(event.pubKey) + val originalUser = localCache.getUserIfExists(originalEvent.pubKey) + + Column { + // Repost header: overlapping avatars + "Boosted" label + Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - zapCount = note.zaps.size, - zapAmountSats = zapAmount.toLong(), - zapReceipts = emptyList(), // TODO: extract ZapReceipts from Note.zaps - reactionCount = reactionCount, - replyCount = replyCount, - repostCount = repostCount, + ) { + GenericRepostLayout( + baseAuthorPicture = { + UserAvatar( + userHex = originalEvent.pubKey, + pictureUrl = originalUser?.profilePicture(), + size = 35.dp, + ) + }, + repostAuthorPicture = { + UserAvatar( + userHex = event.pubKey, + pictureUrl = reposterUser?.profilePicture(), + size = 35.dp, + ) + }, + ) + BoostedMark() + } + + // Original note content + NoteCard( + note = originalEvent.toNoteDisplayData(localCache), + localCache = localCache, + onClick = { onNavigateToThread(originalEvent.id) }, + onAuthorClick = onNavigateToProfile, + onMentionClick = onNavigateToProfile, + onImageClick = onImageClick, + onMediaClick = onMediaClick, ) + + // Action buttons for original note + if (account != null) { + NoteActionsRow( + event = originalEvent, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onReplyClick = onReply, + onZapFeedback = onZapFeedback, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + zapCount = originalNote.zaps.size, + zapAmountSats = zapAmount.toLong(), + zapReceipts = emptyList(), + reactionCount = reactionCount, + replyCount = replyCount, + repostCount = repostCount, + ) + } + } + } else { + // Regular note rendering (unchanged) + val flowSet = remember(note) { note.flow() } + val reactionsState by flowSet.reactions.stateFlow.collectAsState() + val repliesState by flowSet.replies.stateFlow.collectAsState() + val zapsState by flowSet.zaps.stateFlow.collectAsState() + + val reactionCount = note.countReactions() + val replyCount = note.replies.size + val repostCount = note.boosts.size + val zapAmount = note.zapsAmount + + DisposableEffect(note) { + onDispose { note.clearFlow() } + } + + Column { + NoteCard( + note = event.toNoteDisplayData(localCache), + localCache = localCache, + onClick = { onNavigateToThread(event.id) }, + onAuthorClick = onNavigateToProfile, + onMentionClick = onNavigateToProfile, + onImageClick = onImageClick, + onMediaClick = onMediaClick, + ) + + if (account != null) { + NoteActionsRow( + event = event, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onReplyClick = onReply, + onZapFeedback = onZapFeedback, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + zapCount = note.zaps.size, + zapAmountSats = zapAmount.toLong(), + zapReceipts = emptyList(), + reactionCount = reactionCount, + replyCount = replyCount, + repostCount = repostCount, + ) + } } } } @@ -248,16 +350,115 @@ fun FeedScreen( val feedState by viewModel.feedState.feedContent.collectAsState() - // Load metadata for visible notes via Coordinator (rate-limited) + // Load metadata for visible notes + repost/quoted note authors via Coordinator LaunchedEffect(feedState, subscriptionsCoordinator) { if (subscriptionsCoordinator != null && feedState is FeedState.Loaded) { val notes = viewModel.feedState.visibleNotes() if (notes.isNotEmpty()) { subscriptionsCoordinator.loadMetadataForNotes(notes) + + // Also load metadata for repost original + quoted note authors + val referencedAuthors = + notes + .filter { it.event is RepostEvent || it.event is GenericRepostEvent } + .mapNotNull { + it.replyTo + ?.lastOrNull() + ?.author + ?.pubkeyHex + } + subscriptionsCoordinator.loadMetadataForPubkeys(referencedAuthors) } } } + // Fetch missing referenced notes (repost originals + quoted notes via e-tags) + // Uses a direct relay subscription — bypasses the coordinator pipeline + val missingNoteIds = + remember(feedState) { + if (feedState !is FeedState.Loaded) return@remember emptyList() + val notes = viewModel.feedState.visibleNotes() + + // Repost originals where event is null + val repostOriginals = + notes + .filter { it.event is RepostEvent || it.event is GenericRepostEvent } + .mapNotNull { it.replyTo?.lastOrNull() } + val repostIds = repostOriginals.filter { it.event == null }.map { it.idHex } + + // Quoted note IDs from content bech32s (nostr:nevent/nostr:note references) + val allEvents = (notes + repostOriginals.filter { it.event != null }).mapNotNull { it.event } + val contentQuotedIds = + allEvents + .flatMap { event -> + UrlParser().parseValidUrls(event.content).bech32s.mapNotNull { bech32 -> + when (val entity = Nip19Parser.uriToRoute(bech32)?.entity) { + is NNote -> entity.hex + is NEvent -> entity.hex + else -> null + } + } + }.filter { localCache.getNoteIfExists(it)?.event == null } + + (repostIds + contentQuotedIds).distinct() + } + + rememberSubscription(allRelayUrls, missingNoteIds, relayManager = relayManager) { + if (allRelayUrls.isEmpty() || missingNoteIds.isEmpty()) return@rememberSubscription null + SubscriptionConfig( + subId = generateSubId("fetch-referenced"), + filters = listOf(FilterBuilders.byIds(missingNoteIds)), + relays = allRelayUrls, + onEvent = { event, _, relay, _ -> + subscriptionsCoordinator?.consumeEvent(event, relay) + }, + ) + } + + // Fetch missing metadata (kind 0) for all note authors including referenced notes + val missingAuthorPubkeys = + remember(feedState) { + if (feedState !is FeedState.Loaded) return@remember emptyList() + val notes = viewModel.feedState.visibleNotes() + + // Collect all referenced notes (repost originals + e-tag/content referenced) + val repostOriginals = + notes + .filter { it.event is RepostEvent || it.event is GenericRepostEvent } + .mapNotNull { it.replyTo?.lastOrNull() } + + // All notes in cache that are referenced by visible notes + val allEvents = (notes + repostOriginals.filter { it.event != null }).mapNotNull { it.event } + val quotedNotes = + allEvents.flatMap { event -> + UrlParser().parseValidUrls(event.content).bech32s.mapNotNull { bech32 -> + when (val entity = Nip19Parser.uriToRoute(bech32)?.entity) { + is NNote -> localCache.getNoteIfExists(entity.hex) + is NEvent -> localCache.getNoteIfExists(entity.hex) + else -> null + } + } + } + + // Authors from feed notes + repost originals + quoted notes + (notes.mapNotNull { it.author } + repostOriginals.mapNotNull { it.author } + quotedNotes.mapNotNull { it.author }) + .filter { it.profilePicture() == null } + .map { it.pubkeyHex } + .distinct() + } + + rememberSubscription(allRelayUrls, missingAuthorPubkeys, relayManager = relayManager) { + if (allRelayUrls.isEmpty() || missingAuthorPubkeys.isEmpty()) return@rememberSubscription null + SubscriptionConfig( + subId = generateSubId("fetch-metadata"), + filters = listOf(FilterBuilders.userMetadataMultiple(missingAuthorPubkeys)), + relays = allRelayUrls, + onEvent = { event, _, relay, _ -> + subscriptionsCoordinator?.consumeEvent(event, relay) + }, + ) + } + // Request interaction subscriptions — keyed on feedMode (stable), not feedState (changes every 250ms) DisposableEffect(feedMode, subscriptionsCoordinator) { val coordinator = subscriptionsCoordinator ?: return@DisposableEffect onDispose {} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index 44417e19a..7cc2500c0 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -38,6 +38,9 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -63,6 +66,7 @@ import com.vitorpamplona.amethyst.desktop.ui.media.AudioPlayer import com.vitorpamplona.amethyst.desktop.ui.media.DesktopVideoPlayer import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState import com.vitorpamplona.amethyst.desktop.ui.media.isAnimatedGifUrl +import com.vitorpamplona.amethyst.desktop.ui.toNoteDisplayData import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.nip19Bech32.entities.NNote @@ -324,6 +328,7 @@ fun NoteCard( private data class ResolvedMention( val displayText: String, val pubKeyHex: String? = null, + val noteIdHex: String? = null, ) /** @@ -354,11 +359,11 @@ private fun resolveBech32( } is NNote -> { - ResolvedMention("note:${entity.hex.take(8)}...") + ResolvedMention(displayText = "note:${entity.hex.take(8)}...", noteIdHex = entity.hex) } is NEvent -> { - ResolvedMention("note:${entity.hex.take(8)}...") + ResolvedMention(displayText = "note:${entity.hex.take(8)}...", noteIdHex = entity.hex) } else -> { @@ -386,12 +391,37 @@ fun extractMentionedPubkeys(bech32s: Set): List = * URLs are underlined in primary color; bech32 mentions show as @displayName in primary color * and navigate to profile on click. */ +private data class ContentSegment( + val start: Int, + val raw: String, + val isUrl: Boolean, +) + +private fun buildSegments( + content: String, + schemeUrls: Collection, + bech32s: Collection, +): List { + val segments = mutableListOf() + for (url in schemeUrls) { + val idx = content.indexOf(url) + if (idx != -1) segments.add(ContentSegment(idx, url, true)) + } + for (bech32 in bech32s) { + val idx = content.indexOf(bech32) + if (idx != -1) segments.add(ContentSegment(idx, bech32, false)) + } + segments.sortBy { it.start } + return segments +} + @Composable fun RichTextContent( content: String, urls: Urls, localCache: DesktopLocalCache? = null, onMentionClick: ((String) -> Unit)? = null, + onNavigateToThread: ((String) -> Unit)? = null, modifier: Modifier = Modifier, ) { val defaultColor = MaterialTheme.colorScheme.onSurface @@ -404,80 +434,159 @@ fun RichTextContent( color = defaultColor, modifier = modifier, ) + return + } + + // Resolve bech32s to find quoted notes vs inline mentions + val resolvedBech32s = + remember(urls.bech32s, localCache) { + urls.bech32s.associateWith { resolveBech32(it, localCache) } + } + + // Collect quoted note IDs (nevent/note references) + val quotedBech32s = remember(resolvedBech32s) { resolvedBech32s.filter { it.value.noteIdHex != null }.keys } + val quotedNoteIds = remember(resolvedBech32s) { resolvedBech32s.values.mapNotNull { it.noteIdHex }.toSet() } + + if (quotedNoteIds.isEmpty()) { + // No quoted notes — render everything as annotated text + val segments = remember(content, urls) { buildSegments(content, urls.withScheme, urls.bech32s) } + RichAnnotatedText(content, segments, resolvedBech32s, defaultColor, primaryColor, onMentionClick, modifier) } else { - data class Segment( - val start: Int, - val raw: String, - val isUrl: Boolean, - ) - - val segments = mutableListOf() - for (url in urls.withScheme) { - val idx = content.indexOf(url) - if (idx != -1) segments.add(Segment(idx, url, true)) - } - for (bech32 in urls.bech32s) { - val idx = content.indexOf(bech32) - if (idx != -1) segments.add(Segment(idx, bech32, false)) - } - segments.sortBy { it.start } - - val annotatedText = - buildAnnotatedString { - var lastIndex = 0 - - for (segment in segments) { - if (segment.start < lastIndex) continue - - // Add text before segment - if (segment.start > lastIndex) { - append(content.substring(lastIndex, segment.start)) + // Has quoted notes — render text + embedded note cards + Column(modifier = modifier) { + // Strip quoted note bech32 references from text + val strippedText = + remember(content, quotedBech32s) { + var text = content + for (bech32 in quotedBech32s) { + text = text.replace(bech32, "").trim() } - - if (segment.isUrl) { - withStyle( - SpanStyle( - color = primaryColor, - textDecoration = TextDecoration.Underline, - ), - ) { - append(segment.raw) - } - } else { - val resolved = resolveBech32(segment.raw, localCache) - if (resolved.pubKeyHex != null && onMentionClick != null) { - val pubKey = resolved.pubKeyHex - withLink( - LinkAnnotation.Clickable( - tag = "mention", - styles = TextLinkStyles(SpanStyle(color = primaryColor)), - ) { - onMentionClick(pubKey) - }, - ) { - append(resolved.displayText) - } - } else { - withStyle(SpanStyle(color = primaryColor)) { - append(resolved.displayText) - } - } - } - - lastIndex = segment.start + segment.raw.length + text } - // Add remaining text - if (lastIndex < content.length) { - append(content.substring(lastIndex)) - } + if (strippedText.isNotBlank()) { + val inlineBech32s = urls.bech32s - quotedBech32s + val segments = remember(strippedText, urls, inlineBech32s) { buildSegments(strippedText, urls.withScheme, inlineBech32s) } + RichAnnotatedText(strippedText, segments, resolvedBech32s, defaultColor, primaryColor, onMentionClick) } - Text( - text = annotatedText, - style = MaterialTheme.typography.bodyMedium, - color = defaultColor, - modifier = modifier, - ) + // Render quoted notes as embedded cards + for (noteId in quotedNoteIds) { + Spacer(Modifier.height(8.dp)) + QuotedNoteEmbed(noteId, localCache, onMentionClick, onNavigateToThread) + } + } } } + +/** + * Renders a quoted note by ID. Observes the note's metadata flow so it + * recomposes when the event arrives asynchronously from a relay fetch. + * Also observes the author's metadata for display name / avatar updates. + */ +@Composable +fun QuotedNoteEmbed( + noteId: String, + localCache: DesktopLocalCache?, + onMentionClick: ((String) -> Unit)? = null, + onNavigateToThread: ((String) -> Unit)? = null, +) { + if (localCache == null) return + + // getOrCreateNote ensures a placeholder exists so subscriptions can find it + val note = remember(noteId) { localCache.getOrCreateNote(noteId) } + + // Observe note metadata flow — recomposes when loadEvent() is called + val flowSet = remember(note) { note.flow() } + val metadataState by flowSet.metadata.stateFlow.collectAsState() + + DisposableEffect(note) { + onDispose { note.clearFlow() } + } + + val event = note.event + if (event != null) { + // Recompute on every recomposition — picks up user metadata changes + val displayData = event.toNoteDisplayData(localCache) + + NoteCard( + note = displayData, + localCache = localCache, + onClick = onNavigateToThread?.let { nav -> { nav(event.id) } }, + onAuthorClick = onMentionClick, + onMentionClick = onMentionClick, + ) + } else { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ), + ) { + Text( + "Loading quoted note...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(12.dp), + ) + } + } +} + +@Composable +private fun RichAnnotatedText( + content: String, + segments: List, + resolvedBech32s: Map, + defaultColor: androidx.compose.ui.graphics.Color, + primaryColor: androidx.compose.ui.graphics.Color, + onMentionClick: ((String) -> Unit)?, + modifier: Modifier = Modifier, +) { + val annotatedText = + buildAnnotatedString { + var lastIndex = 0 + for (seg in segments) { + if (seg.start < lastIndex) continue + if (seg.start > lastIndex) { + append(content.substring(lastIndex, seg.start)) + } + if (seg.isUrl) { + withStyle(SpanStyle(color = primaryColor, textDecoration = TextDecoration.Underline)) { + append(seg.raw) + } + } else { + val resolved = resolvedBech32s[seg.raw] ?: ResolvedMention(seg.raw) + if (resolved.pubKeyHex != null && onMentionClick != null) { + val pubKey = resolved.pubKeyHex + withLink( + LinkAnnotation.Clickable( + tag = "mention", + styles = TextLinkStyles(SpanStyle(color = primaryColor)), + ) { + onMentionClick(pubKey) + }, + ) { + append(resolved.displayText) + } + } else { + withStyle(SpanStyle(color = primaryColor)) { + append(resolved.displayText) + } + } + } + lastIndex = seg.start + seg.raw.length + } + if (lastIndex < content.length) { + append(content.substring(lastIndex)) + } + } + + Text( + text = annotatedText, + style = MaterialTheme.typography.bodyMedium, + color = defaultColor, + modifier = modifier, + ) +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/filters/FilterBuildersTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/filters/FilterBuildersTest.kt index 162b178b8..f1fdbacbf 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/filters/FilterBuildersTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/filters/FilterBuildersTest.kt @@ -37,7 +37,7 @@ class FilterBuildersTest { fun testTextNotesGlobal() { val filter = FilterBuilders.textNotesGlobal(limit = 50) - assertEquals(listOf(1), filter.kinds) + assertEquals(listOf(1, 6, 16), filter.kinds) assertEquals(50, filter.limit) assertNull(filter.authors) assertNull(filter.tags) @@ -51,7 +51,7 @@ class FilterBuildersTest { val until = 1640995200L // 2022-01-01 val filter = FilterBuilders.textNotesGlobal(limit = 100, since = since, until = until) - assertEquals(listOf(1), filter.kinds) + assertEquals(listOf(1, 6, 16), filter.kinds) assertEquals(100, filter.limit) assertEquals(since, filter.since) assertEquals(until, filter.until) @@ -62,7 +62,7 @@ class FilterBuildersTest { val authors = listOf(testPubKey, testPubKey2) val filter = FilterBuilders.textNotesFromAuthors(authors, limit = 25) - assertEquals(listOf(1), filter.kinds) + assertEquals(listOf(1, 6, 16), filter.kinds) assertEquals(authors, filter.authors) assertEquals(25, filter.limit) assertNull(filter.tags) @@ -74,7 +74,7 @@ class FilterBuildersTest { val since = 1609459200L val filter = FilterBuilders.textNotesFromAuthors(authors, limit = 10, since = since) - assertEquals(listOf(1), filter.kinds) + assertEquals(listOf(1, 6, 16), filter.kinds) assertEquals(authors, filter.authors) assertEquals(10, filter.limit) assertEquals(since, filter.since) @@ -421,7 +421,7 @@ class FilterBuildersTest { val filter = FilterBuilders.textNotesGlobal(limit = 50) assertTrue(!filter.isEmpty()) - assertEquals(listOf(1), filter.kinds) + assertEquals(listOf(1, 6, 16), filter.kinds) assertEquals(50, filter.limit) } @@ -431,7 +431,7 @@ class FilterBuildersTest { val filter = FilterBuilders.textNotesFromAuthors(followedUsers, limit = 50) assertTrue(!filter.isEmpty()) - assertEquals(listOf(1), filter.kinds) + assertEquals(listOf(1, 6, 16), filter.kinds) assertEquals(followedUsers, filter.authors) assertEquals(50, filter.limit) } @@ -447,7 +447,7 @@ class FilterBuildersTest { assertTrue(!contactListFilter.isEmpty()) assertEquals(listOf(0), metadataFilter.kinds) - assertEquals(listOf(1), postsFilter.kinds) + assertEquals(listOf(1, 6, 16), postsFilter.kinds) assertEquals(listOf(3), contactListFilter.kinds) } From 882c39fd0e81a643ca43e33666da80f994c2859c Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 30 Mar 2026 11:31:59 +0300 Subject: [PATCH 05/15] fix(cache): use getOrCreateNote for reply linking to fix flaky thread test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #2001 — ThreadFilter test failed intermittently because consumeTextNote used getNoteIfExists for reply-to linking. If the reply event arrived before the root, the link was lost. Now uses getOrCreateNote to create placeholders, same pattern as reposts. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index 140fecc46..f0ad8611b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -228,7 +228,7 @@ class DesktopLocalCache : ICacheProvider { val note = getOrCreateNote(event.id) if (note.event != null) return false val author = getOrCreateUser(event.pubKey) - val repliesTo = event.tagsWithoutCitations().mapNotNull { getNoteIfExists(it) } + val repliesTo = event.tagsWithoutCitations().map { getOrCreateNote(it) } note.loadEvent(event, author, repliesTo) relay?.let { note.addRelay(it) } repliesTo.forEach { it.addReply(note) } From 0a41806d7ed1afc7a58ec89320e3e7d41bd72019 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 30 Mar 2026 13:33:18 +0300 Subject: [PATCH 06/15] =?UTF-8?q?fix(desktop):=20feed=20loading=20issues?= =?UTF-8?q?=20=E2=80=94=20missing=20events,=20broken=20profile=20navigatio?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Profile feed now includes reposts (kind 6/16), not just text notes - Metadata consume returns false to avoid wasteful null note lookups - Feed subscription limits raised from 50 to 200 for global/following - Thread replies subscription fetches NIP-22 comments (kind 1111) - Cache now handles CommentEvent for thread display - Wire onNavigateToThread through UserProfileScreen so tapping notes opens threads Co-Authored-By: Claude Opus 4.6 (1M context) --- .../desktop/cache/DesktopLocalCache.kt | 25 ++++++++++++++++++- .../desktop/feeds/DesktopFeedFilters.kt | 25 +++++++++++-------- .../desktop/subscriptions/FeedSubscription.kt | 18 ++++++++++--- .../amethyst/desktop/ui/UserProfileScreen.kt | 2 ++ .../desktop/ui/deck/DeckColumnContainer.kt | 3 +++ 5 files changed, 59 insertions(+), 14 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index f0ad8611b..6fbf9c266 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent @@ -173,7 +174,7 @@ class DesktopLocalCache : ICacheProvider { when (event) { is MetadataEvent -> { consumeMetadata(event) - true + false // metadata updates User, not Note — skip event stream } is TextNoteEvent -> { @@ -212,6 +213,10 @@ class DesktopLocalCache : ICacheProvider { consumeBookmarkList(event) } + is CommentEvent -> { + consumeComment(event, relay) + } + else -> { false } @@ -235,6 +240,24 @@ class DesktopLocalCache : ICacheProvider { return true } + /** + * Consumes a kind 1111 comment event (NIP-22). + * Like text notes but uses BaseThreadedEvent reply structure. + */ + private fun consumeComment( + event: CommentEvent, + relay: NormalizedRelayUrl?, + ): Boolean { + val note = getOrCreateNote(event.id) + if (note.event != null) return false + val author = getOrCreateUser(event.pubKey) + val repliesTo = event.tagsWithoutCitations().map { getOrCreateNote(it) } + note.loadEvent(event, author, repliesTo) + relay?.let { note.addRelay(it) } + repliesTo.forEach { it.addReply(note) } + return true + } + /** * Consumes a kind 7 reaction event. * Links reaction to target notes via e-tags and a-tags. diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt index 6aac787c9..a9d4cdead 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt @@ -34,7 +34,10 @@ import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -private fun isFeedNote(event: com.vitorpamplona.quartz.nip01Core.core.Event?): Boolean = event is TextNoteEvent || event is RepostEvent || event is GenericRepostEvent +private fun isFeedNote(event: com.vitorpamplona.quartz.nip01Core.core.Event?): Boolean = + event is TextNoteEvent || + event is RepostEvent || + event is GenericRepostEvent private fun List.deduplicateReposts(): List = distinctBy { note -> @@ -132,7 +135,7 @@ class DesktopThreadFilter( } /** - * Profile feed: all kind 1 notes by a specific pubkey. + * Profile feed: text notes + reposts by a specific pubkey. */ class DesktopProfileFeedFilter( private val pubkey: HexKey, @@ -140,19 +143,21 @@ class DesktopProfileFeedFilter( ) : AdditiveFeedFilter() { override fun feedKey(): String = "profile-$pubkey" + private fun isProfileNote(note: Note): Boolean { + val event = note.event ?: return false + return note.author?.pubkeyHex == pubkey && isFeedNote(event) + } + override fun feed(): List = cache.notes - .filterIntoSet { _, note -> - note.event is TextNoteEvent && note.author?.pubkeyHex == pubkey - }.sortedWith(DefaultFeedOrder) + .filterIntoSet { _, note -> isProfileNote(note) } + .sortedWith(DefaultFeedOrder) + .deduplicateReposts() .take(limit()) - override fun applyFilter(newItems: Set): Set = - newItems.filterTo(HashSet()) { - it.event is TextNoteEvent && it.author?.pubkeyHex == pubkey - } + override fun applyFilter(newItems: Set): Set = newItems.filterTo(HashSet()) { isProfileNote(it) } - override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder).deduplicateReposts() override fun limit(): Int = 1000 } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt index a7d4a633a..589e500cf 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt @@ -37,7 +37,7 @@ enum class FeedMode { */ fun createGlobalFeedSubscription( relays: Set, - limit: Int = 50, + limit: Int = 200, onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, ): SubscriptionConfig = @@ -55,7 +55,7 @@ fun createGlobalFeedSubscription( fun createFollowingFeedSubscription( relays: Set, followedUsers: List, - limit: Int = 50, + limit: Int = 200, onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, ): SubscriptionConfig? { @@ -121,9 +121,21 @@ fun createThreadRepliesSubscription( subId = generateSubId("thread-${noteId.take(8)}"), filters = listOf( + // Kind 1 replies via lowercase e-tag (NIP-10) FilterBuilders.byETags( eventIds = listOf(noteId), - kinds = listOf(1), // TextNoteEvent + kinds = listOf(1), + limit = limit, + ), + // Kind 1111 comments via lowercase e-tag (reply parent) or uppercase E-tag (root) + FilterBuilders.byTags( + tags = mapOf("e" to listOf(noteId)), + kinds = listOf(1111), + limit = limit, + ), + FilterBuilders.byTags( + tags = mapOf("E" to listOf(noteId)), + kinds = listOf(1111), limit = limit, ), ), diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 0c9582151..914f6fcbd 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -121,6 +121,7 @@ fun UserProfileScreen( onBack: () -> Unit, onCompose: () -> Unit = {}, onNavigateToProfile: (String) -> Unit = {}, + onNavigateToThread: (String) -> Unit = {}, onNavigateToArticle: (String) -> Unit = {}, onZapFeedback: (ZapFeedback) -> Unit = {}, ) { @@ -796,6 +797,7 @@ fun UserProfileScreen( onReply = onCompose, onZapFeedback = onZapFeedback, onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) }, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index 8d8f01805..a72032acd 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -296,6 +296,7 @@ internal fun RootContent( onBack = {}, onCompose = onShowComposeDialog, onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, onNavigateToArticle = onNavigateToArticle, onZapFeedback = onZapFeedback, ) @@ -325,6 +326,7 @@ internal fun RootContent( onBack = {}, onCompose = onShowComposeDialog, onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, onZapFeedback = onZapFeedback, ) } @@ -426,6 +428,7 @@ internal fun OverlayContent( onBack = onBack, onCompose = onShowComposeDialog, onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, onNavigateToArticle = onNavigateToArticle, onZapFeedback = onZapFeedback, ) From 5f0fd52f26e2a50f111146e6fcfb1be72225f874 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 12:17:03 +0000 Subject: [PATCH 07/15] refactor: remove nip88PollApps discovery package (out of scope) https://claude.ai/code/session_013r2LXj11SieWa5PaLesKfC --- .../DiscoverNIP89PollAppsFeedFilter.kt | 47 ---------- .../nip88PollApps/SubAssemblyHelper.kt | 45 --------- .../subassemblies/FilterPollAppsByAuthors.kt | 94 ------------------- .../subassemblies/FilterPollAppsByFollows.kt | 44 --------- .../subassemblies/FilterPollAppsGlobal.kt | 52 ---------- 5 files changed, 282 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/DiscoverNIP89PollAppsFeedFilter.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/SubAssemblyHelper.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/subassemblies/FilterPollAppsByAuthors.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/subassemblies/FilterPollAppsByFollows.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/subassemblies/FilterPollAppsGlobal.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/DiscoverNIP89PollAppsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/DiscoverNIP89PollAppsFeedFilter.kt deleted file mode 100644 index 8ff75b3ec..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/DiscoverNIP89PollAppsFeedFilter.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * 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.amethyst.ui.screen.loggedIn.discover.nip88PollApps - -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.DiscoverNIP89FeedFilter -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent -import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent - -/** - * NIP-89 discovery feed filter for poll-supporting applications. - * - * Discovers [AppDefinitionEvent]s that declare support for [PollEvent.KIND] (1068), - * linking NIP-88 polls to NIP-89 app handler discovery. - */ -class DiscoverNIP89PollAppsFeedFilter( - account: Account, -) : DiscoverNIP89FeedFilter(account, PollEvent.KIND) { - override fun acceptApp( - noteEvent: AppDefinitionEvent, - relays: List, - ): Boolean { - val filterParams = buildFilterParams(account) - return filterParams.match(noteEvent, relays) && - noteEvent.includeKind(targetKind) && - noteEvent.createdAt > lastAnnounced - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/SubAssemblyHelper.kt deleted file mode 100644 index 0f6c09efa..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/SubAssemblyHelper.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* - * 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.amethyst.ui.screen.loggedIn.discover.nip88PollApps - -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip88PollApps.subassemblies.filterPollAppsByAuthors -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip88PollApps.subassemblies.filterPollAppsByFollows -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip88PollApps.subassemblies.filterPollAppsGlobal -import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter - -fun makePollAppsFilter( - feedSettings: IFeedTopNavPerRelayFilterSet, - since: SincePerRelayMap?, - defaultSince: Long?, -): List = - when (feedSettings) { - is AllFollowsTopNavPerRelayFilterSet -> filterPollAppsByFollows(feedSettings, since, defaultSince) - is AuthorsTopNavPerRelayFilterSet -> filterPollAppsByAuthors(feedSettings, since, defaultSince) - is GlobalTopNavPerRelayFilterSet -> filterPollAppsGlobal(feedSettings, since, defaultSince) - is MutedAuthorsTopNavPerRelayFilterSet -> filterPollAppsByAuthors(feedSettings, since, defaultSince) - else -> emptyList() - } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/subassemblies/FilterPollAppsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/subassemblies/FilterPollAppsByAuthors.kt deleted file mode 100644 index 74b2bc553..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/subassemblies/FilterPollAppsByAuthors.kt +++ /dev/null @@ -1,94 +0,0 @@ -/* - * 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.amethyst.ui.screen.loggedIn.discover.nip88PollApps.subassemblies - -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent -import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent - -fun filterPollAppsAuthors( - relay: NormalizedRelayUrl, - authors: Set, - since: Long? = null, -): List { - val authorList = authors.sorted() - return listOf( - RelayBasedFilter( - relay = relay, - filter = - Filter( - authors = authorList, - kinds = listOf(AppDefinitionEvent.KIND), - tags = mapOf("k" to listOf(PollEvent.KIND.toString())), - limit = 300, - since = since, - ), - ), - ) -} - -fun filterPollAppsByAuthors( - authorSet: AuthorsTopNavPerRelayFilterSet, - since: SincePerRelayMap?, - defaultSince: Long? = null, -): List { - if (authorSet.set.isEmpty()) return emptyList() - - return authorSet.set - .mapNotNull { - if (it.value.authors.isEmpty()) { - null - } else { - filterPollAppsAuthors( - relay = it.key, - authors = it.value.authors, - since = since?.get(it.key)?.time ?: defaultSince, - ) - } - }.flatten() -} - -fun filterPollAppsByAuthors( - authorSet: MutedAuthorsTopNavPerRelayFilterSet, - since: SincePerRelayMap?, - defaultSince: Long? = null, -): List { - if (authorSet.set.isEmpty()) return emptyList() - - return authorSet.set - .mapNotNull { - if (it.value.authors.isEmpty()) { - null - } else { - filterPollAppsAuthors( - relay = it.key, - authors = it.value.authors, - since = since?.get(it.key)?.time ?: defaultSince, - ) - } - }.flatten() -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/subassemblies/FilterPollAppsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/subassemblies/FilterPollAppsByFollows.kt deleted file mode 100644 index ddd49e3db..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/subassemblies/FilterPollAppsByFollows.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * 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.amethyst.ui.screen.loggedIn.discover.nip88PollApps.subassemblies - -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap -import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter - -fun filterPollAppsByFollows( - followsSet: AllFollowsTopNavPerRelayFilterSet, - since: SincePerRelayMap?, - defaultSince: Long? = null, -): List { - if (followsSet.set.isEmpty()) return emptyList() - - return followsSet.set.flatMap { - val since = since?.get(it.key)?.time ?: defaultSince - val relay = it.key - - listOfNotNull( - it.value.authors?.let { - filterPollAppsAuthors(relay, it, since) - }, - ).flatten() - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/subassemblies/FilterPollAppsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/subassemblies/FilterPollAppsGlobal.kt deleted file mode 100644 index e31572d83..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip88PollApps/subassemblies/FilterPollAppsGlobal.kt +++ /dev/null @@ -1,52 +0,0 @@ -/* - * 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.amethyst.ui.screen.loggedIn.discover.nip88PollApps.subassemblies - -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap -import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent -import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent - -fun filterPollAppsGlobal( - relays: GlobalTopNavPerRelayFilterSet, - since: SincePerRelayMap?, - defaultSince: Long? = null, -): List { - if (relays.set.isEmpty()) return emptyList() - - return relays.set.flatMap { - val since = since?.get(it.key)?.time ?: defaultSince - listOf( - RelayBasedFilter( - relay = it.key, - filter = - Filter( - kinds = listOf(AppDefinitionEvent.KIND), - tags = mapOf("k" to listOf(PollEvent.KIND.toString())), - limit = 30, - since = since, - ), - ), - ) - } -} From 3d400d4197840020ac471869497090483ade8e57 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 12:34:51 +0000 Subject: [PATCH 08/15] docs: add CLIENT.md guide for building Nostr clients with Quartz Companion to the existing RELAY.md, this guide covers the client side: Ktor WebSocket transport implementation, subscribing to events (Flow and callback APIs), publishing signed events, filters, key management, and multi-relay patterns. https://claude.ai/code/session_01KFos33kA9VoMhKdBPNLquF --- quartz/CLIENT.md | 562 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 562 insertions(+) create mode 100644 quartz/CLIENT.md diff --git a/quartz/CLIENT.md b/quartz/CLIENT.md new file mode 100644 index 000000000..c7d58b11d --- /dev/null +++ b/quartz/CLIENT.md @@ -0,0 +1,562 @@ +# Building a Nostr Client with Quartz + +This guide walks you through building a Nostr client using Quartz's `NostrClient` and [Ktor](https://ktor.io/) as the WebSocket transport layer. + +## Overview + +Quartz provides a complete, transport-agnostic client engine: + +| Component | Class | Role | +|-----------|-------|------| +| **NostrClient** | `com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient` | Manages relay connections, subscriptions, and event publishing | +| **WebsocketBuilder** | `com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder` | Factory interface for pluggable WebSocket transports | +| **Filter** | `com.vitorpamplona.quartz.nip01Core.relay.filters.Filter` | NIP-01 subscription filters | +| **KeyPair** | `com.vitorpamplona.quartz.nip01Core.crypto.KeyPair` | Schnorr key pair for signing events | +| **NostrSignerInternal** | `com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal` | Signs events with a local private key | + +`NostrClient` doesn't know about HTTP or WebSockets. You provide a `WebsocketBuilder` that creates transport connections, and the client handles relay pooling, subscription management, reconnection, and event delivery. + +## Quick Start + +### 1. Add Dependencies + +In your `build.gradle.kts`: + +```kotlin +dependencies { + // Ktor WebSocket client + implementation("io.ktor:ktor-client-core:3.1.1") + implementation("io.ktor:ktor-client-cio:3.1.1") + implementation("io.ktor:ktor-client-websockets:3.1.1") + + // Quartz (use your local module or published artifact) + implementation(project(":quartz")) +} +``` + +### 2. Implement the Ktor WebSocket Transport + +Quartz uses two interfaces for WebSocket connectivity: + +```kotlin +// WebSocket — a single connection to a relay +interface WebSocket { + fun needsReconnect(): Boolean + fun connect() + fun disconnect() + fun send(msg: String): Boolean +} + +// WebsocketBuilder — factory that creates WebSocket instances +interface WebsocketBuilder { + fun build(url: NormalizedRelayUrl, out: WebSocketListener): WebSocket +} + +// WebSocketListener — callbacks from the transport layer +interface WebSocketListener { + fun onOpen(pingMillis: Int, compression: Boolean) + fun onMessage(text: String) // must deliver messages in order + fun onClosed(code: Int, reason: String) + fun onFailure(t: Throwable, code: Int?, response: String?) +} +``` + +Here's a complete Ktor implementation: + +```kotlin +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.websocket.* +import io.ktor.websocket.* +import kotlinx.coroutines.* + +class KtorWebSocket( + private val url: NormalizedRelayUrl, + private val client: HttpClient, + private val out: WebSocketListener, +) : WebSocket { + private var session: DefaultClientWebSocketSession? = null + private var job: Job? = null + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + override fun needsReconnect() = session == null + + override fun connect() { + job = scope.launch { + try { + client.webSocket(url.url) { + session = this + out.onOpen(0, false) + + try { + for (frame in incoming) { + if (frame is Frame.Text) { + out.onMessage(frame.readText()) + } + } + } finally { + val reason = closeReason.await() + session = null + out.onClosed( + reason?.code?.toInt() ?: 1000, + reason?.message ?: "Connection closed", + ) + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + session = null + out.onFailure(e, null, e.message) + } + } + } + + override fun disconnect() { + job?.cancel() + job = null + session = null + } + + override fun send(msg: String): Boolean { + val currentSession = session ?: return false + return try { + runBlocking { currentSession.send(Frame.Text(msg)) } + true + } catch (e: Exception) { + false + } + } + + class Builder( + private val client: HttpClient, + ) : WebsocketBuilder { + override fun build( + url: NormalizedRelayUrl, + out: WebSocketListener, + ) = KtorWebSocket(url, client, out) + } +} +``` + +### 3. Subscribe to Events (Flow API) + +The simplest way to receive events — `subscribeAsFlow` returns a `Flow>` that accumulates events as they arrive: + +```kotlin +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.websocket.* +import kotlinx.coroutines.* + +fun main() = runBlocking { + // 1. Create the Ktor HTTP client with WebSocket support + val httpClient = HttpClient(CIO) { + install(WebSockets) + } + + // 2. Create the NostrClient + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = NostrClient(KtorWebSocket.Builder(httpClient), scope) + + // 3. Subscribe to text notes (kind 1) from a relay + val flow = client.subscribeAsFlow( + relay = "wss://nos.lol", + filter = Filter(kinds = listOf(1), limit = 20), + ) + + // 4. Collect events as they arrive + val job = launch { + flow.collect { events -> + println("Got ${events.size} events so far") + events.forEach { event -> + println(" [${event.pubKey.take(8)}...] ${event.content.take(80)}") + } + } + } + + // Let it run for 10 seconds + delay(10_000) + job.cancel() + + // 5. Clean up + client.disconnect() + scope.cancel() + httpClient.close() +} +``` + +### 4. Subscribe to Events (Callback API) + +For more control, use the manual subscription API with `SubscriptionListener`: + +```kotlin +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlinx.coroutines.* + +fun main() = runBlocking { + val httpClient = HttpClient(CIO) { install(WebSockets) } + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = NostrClient(KtorWebSocket.Builder(httpClient), scope) + + // Define a listener for subscription events + val listener = object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + val label = if (isLive) "LIVE" else "STORED" + println("[$label] ${event.pubKey.take(8)}...: ${event.content.take(80)}") + } + + override fun onEose(relay: NormalizedRelayUrl, forFilters: List?) { + println("--- End of stored events from $relay ---") + } + } + + // Build filters per relay + val filters = mapOf( + RelayUrlNormalizer.normalize("wss://nos.lol") to listOf( + Filter(kinds = listOf(1), limit = 50), + ), + ) + + // Subscribe + client.subscribe("my-feed", filters, listener) + + delay(15_000) + + // Unsubscribe and clean up + client.unsubscribe("my-feed") + client.disconnect() + scope.cancel() + httpClient.close() +} +``` + +### 5. Publish Events + +Create a key pair, sign an event, and publish it to relays: + +```kotlin +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import kotlinx.coroutines.* + +fun main() = runBlocking { + val httpClient = HttpClient(CIO) { install(WebSockets) } + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = NostrClient(KtorWebSocket.Builder(httpClient), scope) + + // 1. Create a key pair (generates random keys) + val signer = NostrSignerInternal(KeyPair()) + println("Public key: ${signer.pubKey}") + + // 2. Build and sign a text note (kind 1) + val event = signer.sign(TextNoteEvent.build("Hello from Quartz!")) + println("Event ID: ${event.id}") + + // 3. Publish and wait for relay confirmation + val accepted = client.publishAndConfirm( + event = event, + relayList = setOf( + "wss://nos.lol".normalizeRelayUrl(), + "wss://relay.damus.io".normalizeRelayUrl(), + ), + ) + + println("Published: $accepted") + + // 4. Clean up + client.disconnect() + scope.cancel() + httpClient.close() +} +``` + +For detailed per-relay results, use `publishAndConfirmDetailed`: + +```kotlin +val results = client.publishAndConfirmDetailed(event, relayList) +results.forEach { (relay, success) -> + println(" $relay: ${if (success) "accepted" else "rejected"}") +} +``` + +## How It Works + +### Architecture + +``` +┌──────────────────────────────────────────────┐ +│ Your Application │ +│ │ +│ subscribeAsFlow() / subscribe() / publish()│ +└─────────────────────┬────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────┐ +│ NostrClient │ +│ │ +│ PoolRequests (active subscriptions) │ +│ PoolCounts (count queries) │ +│ PoolEventOutbox (pending publishes) │ +└─────────────────────┬────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────┐ +│ RelayPool │ +│ │ +│ Manages BasicRelayClient per relay URL │ +│ Auto-connects/disconnects as needed │ +│ Tracks connected/available relay state │ +└─────────────────────┬────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────┐ +│ BasicRelayClient (per relay) │ +│ │ +│ Connection lifecycle + reconnection │ +│ NIP-01 message parsing │ +│ Exponential backoff on failures │ +└─────────────────────┬────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────┐ +│ WebsocketBuilder → WebSocket │ +│ │ +│ KtorWebSocket (this guide) │ +│ BasicOkHttpWebSocket (built-in for JVM) │ +└──────────────────────────────────────────────┘ +``` + +### Connection Lifecycle + +1. You call `subscribe()` or `publish()` with relay URLs +2. `NostrClient` tells `RelayPool` which relays are needed +3. `RelayPool` creates a `BasicRelayClient` for each new relay +4. `BasicRelayClient` uses `WebsocketBuilder` to create and open a `WebSocket` +5. On connection, all pending subscriptions and events are synced automatically +6. On disconnection, exponential backoff retries the connection +7. On reconnection, filters are re-sent and events re-delivered + +### Subscription Flow + +``` +client.subscribe("my-feed", filters, listener) + │ + ▼ +NostrClient stores filters in PoolRequests + │ + ▼ +RelayPool connects to needed relays + │ + ▼ +BasicRelayClient sends ["REQ", "my-feed", ...] + │ + ▼ +Relay responds with: + ["EVENT", "my-feed", ] → listener.onEvent(event, isLive=false) + ["EVENT", "my-feed", ] → listener.onEvent(event, isLive=false) + ["EOSE", "my-feed"] → listener.onEose(relay) + ["EVENT", "my-feed", ] → listener.onEvent(event, isLive=true) ← new events +``` + +### NIP-01 Commands + +| Direction | Command | Description | +|-----------|---------|-------------| +| Client → Relay | `["REQ", subId, filter...]` | Open a subscription | +| Client → Relay | `["CLOSE", subId]` | Close a subscription | +| Client → Relay | `["EVENT", event]` | Publish an event | +| Relay → Client | `["EVENT", subId, event]` | Deliver a matching event | +| Relay → Client | `["EOSE", subId]` | End of stored events | +| Relay → Client | `["OK", eventId, success, message]` | Publish acknowledgement | +| Relay → Client | `["NOTICE", message]` | Relay notice | + +## Filters + +Filters define what events a subscription matches. All fields are optional — omitted fields match everything. + +```kotlin +// Latest 20 text notes +Filter(kinds = listOf(1), limit = 20) + +// Metadata for specific authors +Filter( + kinds = listOf(0), + authors = listOf( + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + ), +) + +// Events since a timestamp +Filter(kinds = listOf(1), since = 1700000000) + +// Events tagged with a specific pubkey +Filter( + kinds = listOf(1), + tags = mapOf("p" to listOf("32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245")), +) + +// Full-text search (NIP-50, relay must support it) +Filter(kinds = listOf(1), search = "bitcoin", limit = 10) +``` + +### Filter Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `ids` | `List?` | Match specific event IDs (64-char hex) | +| `authors` | `List?` | Match specific author pubkeys (64-char hex) | +| `kinds` | `List?` | Match event kinds (0=metadata, 1=text note, 3=contacts, etc.) | +| `tags` | `Map>?` | Match events with any of the listed tag values | +| `since` | `Long?` | Events with `created_at` >= this Unix timestamp | +| `until` | `Long?` | Events with `created_at` <= this Unix timestamp | +| `limit` | `Int?` | Maximum number of events to return | +| `search` | `String?` | Full-text search (NIP-50) | + +## Key Management + +```kotlin +// Generate a new random key pair +val keyPair = KeyPair() + +// Import an existing private key (32 bytes) +val keyPair = KeyPair(privKey = hexToByteArray("your-64-char-hex-private-key")) + +// Read-only (public key only, cannot sign) +val keyPair = KeyPair(pubKey = hexToByteArray("your-64-char-hex-public-key")) + +// Create a signer for signing events +val signer = NostrSignerInternal(keyPair) +``` + +## Event Kinds + +Common event kinds you'll work with: + +| Kind | NIP | Class | Description | +|------|-----|-------|-------------| +| 0 | NIP-01 | `MetadataEvent` | User metadata (name, picture, about) | +| 1 | NIP-01 | `TextNoteEvent` | Text note (short post) | +| 3 | NIP-02 | `ContactListEvent` | Contact list / follow list | +| 4 | NIP-04 | `PrivateDmEvent` | Encrypted direct message | +| 7 | NIP-25 | `ReactionEvent` | Reaction (like, emoji) | +| 30023 | NIP-23 | `LongTextNoteEvent` | Long-form content (articles) | + +## Multi-Relay Subscriptions + +Subscribe to different filters on different relays: + +```kotlin +val filters = mapOf( + RelayUrlNormalizer.normalize("wss://nos.lol") to listOf( + Filter(kinds = listOf(1), limit = 50), + ), + RelayUrlNormalizer.normalize("wss://relay.damus.io") to listOf( + Filter(kinds = listOf(0, 3), authors = listOf(myPubKey)), + ), +) + +client.subscribe("multi-relay-feed", filters, listener) +``` + +## Using OkHttp Instead of Ktor + +Quartz ships with a built-in OkHttp WebSocket implementation for JVM/Android: + +```kotlin +import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket +import okhttp3.OkHttpClient + +val httpClient = OkHttpClient.Builder() + .followRedirects(true) + .followSslRedirects(true) + .build() + +val socketBuilder = BasicOkHttpWebSocket.Builder { url -> httpClient } +val client = NostrClient(socketBuilder, scope) +``` + +## Full Example: Simple Feed Reader + +```kotlin +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.websocket.* +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.take + +fun main() = runBlocking { + val httpClient = HttpClient(CIO) { install(WebSockets) } + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = NostrClient(KtorWebSocket.Builder(httpClient), scope) + + println("Connecting to relay...") + + client.subscribeAsFlow( + relay = "wss://nos.lol", + filter = Filter(kinds = listOf(1), limit = 10), + ).take(1).collect { events -> + println("\n=== Latest ${events.size} text notes ===\n") + events.forEach { event -> + println("Author: ${event.pubKey.take(16)}...") + println("Content: ${event.content.take(120)}") + println("---") + } + } + + client.disconnect() + scope.cancel() + httpClient.close() +} +``` + +## Key Source Files + +All client infrastructure lives in the `quartz` module: + +``` +quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/ +├── relay/client/ +│ ├── NostrClient.kt # Main client (manages relay pool + subscriptions) +│ ├── INostrClient.kt # Client interface +│ ├── reqs/ +│ │ ├── SubscriptionListener.kt # Callback interface for subscriptions +│ │ └── NostrClientSubscribeAsFlowExt.kt # Flow-based subscription API +│ └── accessories/ +│ └── NostrClientPublishExt.kt # publishAndConfirm / publishAndConfirmDetailed +├── relay/sockets/ +│ ├── WebSocket.kt # Transport interface +│ ├── WebSocketListener.kt # Transport callback interface +│ └── WebsocketBuilder.kt # Transport factory interface +├── relay/filters/ +│ └── Filter.kt # NIP-01 subscription filters +├── crypto/ +│ ├── KeyPair.kt # Schnorr key pair +│ └── Nip01Crypto.kt # Low-level crypto operations +└── signers/ + ├── NostrSigner.kt # Abstract signer + └── NostrSignerInternal.kt # Signs with local private key +``` From df88cab92df6985effb822958552e188c19dc3f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 12:35:27 +0000 Subject: [PATCH 09/15] refactor: simplify relay API with AutoCloseable and serve() helper - NostrServer and IEventStore implement AutoCloseable for .use {} support - Add NostrServer.serve() to handle session lifecycle automatically - IRelayPolicy + operator now returns PolicyStack instead of List - Deprecate shutdown() in favor of close() - Update RELAY.md guide to use simplified API patterns https://claude.ai/code/session_013oL9PkQaFyNQHKVg2vw9qs --- quartz/RELAY.md | 150 +++++++++--------- .../nip01Core/relay/server/IRelayPolicy.kt | 3 +- .../nip01Core/relay/server/NostrServer.kt | 33 +++- .../quartz/nip01Core/store/IEventStore.kt | 4 +- .../relay/server/NostrServerAuthTest.kt | 28 ++-- .../nip01Core/relay/server/NostrServerTest.kt | 22 +-- 6 files changed, 130 insertions(+), 110 deletions(-) diff --git a/quartz/RELAY.md b/quartz/RELAY.md index 88233d26f..dcad2bc25 100644 --- a/quartz/RELAY.md +++ b/quartz/RELAY.md @@ -15,6 +15,8 @@ Quartz provides a complete, transport-agnostic relay engine: `NostrServer` doesn't know about HTTP or WebSockets. You provide a `send` callback per connection, and it gives you a `RelaySession` that accepts raw JSON strings. This makes it trivial to plug into Ktor (or any other transport). +Both `NostrServer` and `EventStore` implement `AutoCloseable`, so you can use Kotlin's `.use {}` for automatic resource cleanup. + ## Quick Start ### 1. Add Dependencies @@ -59,21 +61,15 @@ fun main() { routing { webSocket("/") { - // 4. Register this WebSocket as a relay connection - val session = server.connect { json -> - launch { send(Frame.Text(json)) } - } - - try { - // 5. Forward incoming frames to the relay session + // 4. Serve this WebSocket connection (auto-cleanup on disconnect) + server.serve( + send = { json -> launch { send(Frame.Text(json)) } }, + ) { session -> for (frame in incoming) { if (frame is Frame.Text) { session.receive(frame.readText()) } } - } finally { - // 6. Clean up when the client disconnects - session.close() } } } @@ -83,6 +79,17 @@ fun main() { That's it. You now have a NIP-01 compliant relay running on `ws://localhost:7777`. +The `serve` method creates a `RelaySession`, passes it to your block, and automatically closes the session when the block completes (normally or via exception). No `try`/`finally` needed. + +> **Tip:** If you need direct control over the session lifecycle, use `connect()` with `.use {}`: +> ```kotlin +> server.connect { json -> launch { send(Frame.Text(json)) } }.use { session -> +> for (frame in incoming) { +> if (frame is Frame.Text) session.receive(frame.readText()) +> } +> } +> ``` + ## How It Works ### Connection Lifecycle @@ -91,7 +98,7 @@ That's it. You now have a NIP-01 compliant relay running on `ws://localhost:7777 Client connects via WebSocket │ ▼ -server.connect { json -> send(json) } +server.serve(send) { session -> ... } │ ▼ RelaySession created @@ -106,7 +113,7 @@ server.connect { json -> send(json) } └───────────────────────────────┘ │ ▼ -Client disconnects → session.close() +Client disconnects → session auto-closed │ ▼ All subscriptions cancelled @@ -233,11 +240,20 @@ Validates: - Timestamp is within 10 minutes - Event signature is valid -### Composing Policies with `PolicyStack` +### Composing Policies -Chain multiple policies together. All must approve; the first rejection wins. Policies run in order and can rewrite commands for downstream policies. +Chain multiple policies using the `+` operator or `PolicyStack`. All must approve; the first rejection wins. Policies run in order and can rewrite commands for downstream policies. ```kotlin +// Using the + operator +val server = NostrServer( + store = store, + policyBuilder = { + VerifyPolicy + FullAuthPolicy(relay = "wss://myrelay.example.com/".normalizeRelayUrl()!!) + }, +) + +// Or using PolicyStack for three or more policies val server = NostrServer( store = store, policyBuilder = { @@ -283,10 +299,7 @@ Use it: val server = NostrServer( store = store, policyBuilder = { - PolicyStack( - VerifyPolicy, - KindWhitelistPolicy(allowedKinds = setOf(0, 1, 3, 7, 30023)), - ) + VerifyPolicy + KindWhitelistPolicy(allowedKinds = setOf(0, 1, 3, 7, 30023)) }, ) ``` @@ -307,7 +320,6 @@ val server = NostrServer( ```kotlin import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer -import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyStack import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy import com.vitorpamplona.quartz.nip01Core.store.sqlite.DefaultIndexingStrategy import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore @@ -318,61 +330,43 @@ import io.ktor.server.routing.* import io.ktor.server.websocket.* import io.ktor.websocket.* import kotlinx.coroutines.launch -import java.time.Duration fun main() { - val relayUrl = "wss://myrelay.example.com/" - val store = EventStore( dbName = "relay-events.db", - relay = relayUrl.normalizeRelayUrl(), + relay = "wss://myrelay.example.com/".normalizeRelayUrl(), indexStrategy = DefaultIndexingStrategy( indexEventsByCreatedAtAlone = true, ), ) - val server = NostrServer( + // NostrServer implements AutoCloseable — .use {} ensures clean shutdown + NostrServer( store = store, - policyBuilder = { - PolicyStack( - VerifyPolicy, - // Add your custom policies here - ) - }, - ) + policyBuilder = { VerifyPolicy }, + ).use { server -> + embeddedServer(Netty, port = 7777) { + install(WebSockets) { + pingPeriodMillis = 30_000 + timeoutMillis = 60_000 + maxFrameSize = Long.MAX_VALUE + } - embeddedServer(Netty, port = 7777) { - install(WebSockets) { - pingPeriodMillis = 30_000 - timeoutMillis = 60_000 - maxFrameSize = Long.MAX_VALUE - } - - routing { - webSocket("/") { - val session = server.connect { json -> - launch { send(Frame.Text(json)) } - } - - try { - for (frame in incoming) { - when (frame) { - is Frame.Text -> session.receive(frame.readText()) - else -> { /* ignore binary, ping, pong */ } + routing { + webSocket("/") { + server.serve( + send = { json -> launch { send(Frame.Text(json)) } }, + ) { session -> + for (frame in incoming) { + if (frame is Frame.Text) { + session.receive(frame.readText()) + } } } - } finally { - session.close() } } - } - }.start(wait = true) - - // Clean shutdown - Runtime.getRuntime().addShutdownHook(Thread { - server.shutdown() - store.close() - }) + }.start(wait = true) + } } ``` @@ -388,7 +382,7 @@ fun main() { │ ┌────────────────────────────────────────┐ │ │ │ NostrServer │ │ │ │ │ │ -│ │ connect(send) ──► RelaySession │ │ +│ │ serve(send) { session -> ... } │ │ │ │ │ │ │ │ │ ├─ IRelayPolicy │ │ │ │ │ (validate) │ │ @@ -433,31 +427,29 @@ class MyRelayTest { @Test fun clientCanPublishAndSubscribe() = runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) - val store = EventStore(null) // in-memory - val server = NostrServer( - store = store, + + NostrServer( + store = EventStore(null), policyBuilder = { EmptyPolicy }, parentContext = dispatcher, - ) + ).use { server -> + // Collect messages sent to this client + val messages = mutableListOf() + val session = server.connect { messages.add(it) } - // Collect messages sent to this client - val messages = mutableListOf() - val session = server.connect { messages.add(it) } + // Publish an event + session.receive("""["EVENT",{"id":"${"0".repeat(64)}","pubkey":"${"a".repeat(64)}","created_at":1000,"kind":1,"tags":[],"content":"hello","sig":"${"b".repeat(128)}"}]""") - // Publish an event - session.receive("""["EVENT",{"id":"${"0".repeat(64)}","pubkey":"${"a".repeat(64)}","created_at":1000,"kind":1,"tags":[],"content":"hello","sig":"${"b".repeat(128)}"}]""") + // Verify OK response + assertTrue(messages.any { it.contains("OK") }) - // Verify OK response - assertTrue(messages.any { it.contains("OK") }) + // Subscribe to kind 1 + session.receive("""["REQ","sub1",{"kinds":[1]}]""") - // Subscribe to kind 1 - session.receive("""["REQ","sub1",{"kinds":[1]}]""") - - // Verify we get the event back + EOSE - assertTrue(messages.any { it.contains("EVENT") }) - assertTrue(messages.any { it.contains("EOSE") }) - - server.shutdown() + // Verify we get the event back + EOSE + assertTrue(messages.any { it.contains("EVENT") }) + assertTrue(messages.any { it.contains("EOSE") }) + } } } ``` diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/IRelayPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/IRelayPolicy.kt index 113824d62..249ac7c75 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/IRelayPolicy.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/IRelayPolicy.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyStack /** * Defines custom behavior for this relay. @@ -76,7 +77,7 @@ interface IRelayPolicy { */ fun canSendToSession(event: Event): Boolean = true - operator fun plus(other: IRelayPolicy) = listOf(this, other) + operator fun plus(other: IRelayPolicy): IRelayPolicy = PolicyStack(this, other) } sealed interface PolicyResult { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServer.kt index 78651df5e..f72c9e7b2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServer.kt @@ -41,7 +41,7 @@ class NostrServer( private val store: IEventStore, private val policyBuilder: () -> IRelayPolicy = { VerifyPolicy }, private val parentContext: CoroutineContext = SupervisorJob(), -) { +) : AutoCloseable { private val subStore = LiveEventStore(store) /** Scope for all subscriptions. */ @@ -70,11 +70,38 @@ class NostrServer( } /** - * Shuts down the server, cancelling all subscriptions and sessions. + * Registers a new client connection and serves it for the duration of + * [incoming]. The session is automatically closed when [incoming] returns. + * + * @param send Callback the server uses to send JSON messages to this client. + * @param incoming Suspend block that yields raw JSON strings from the client + * (e.g., reading WebSocket text frames in a loop). */ - fun shutdown() { + suspend fun serve( + send: (String) -> Unit, + incoming: suspend (RelaySession) -> Unit, + ) { + val session = connect(send) + try { + incoming(session) + } finally { + session.close() + } + } + + /** + * Shuts down the server, cancelling all subscriptions and closing the store. + */ + override fun close() { connections.forEach { _, session -> session.cancelAllSubscriptions() } connections.clear() scope.cancel() + store.close() } + + /** + * Shuts down the server, cancelling all subscriptions and closing the store. + */ + @Deprecated("Use close() instead", replaceWith = ReplaceWith("close()")) + fun shutdown() = close() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt index 1d3d74efd..e9268922d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.nip01Core.store import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -interface IEventStore { +interface IEventStore : AutoCloseable { fun insert(event: Event) interface ITransaction { @@ -56,5 +56,5 @@ interface IEventStore { fun deleteExpiredEvents() - fun close() + override fun close() } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt index bc89df39f..4e7200643 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt @@ -142,7 +142,7 @@ class NostrServerAuthTest { assertEquals(1, collector.messages.size) assertTrue(collector.messages[0].contains("\"AUTH\"")) - server.shutdown() + server.close() } @Test @@ -167,7 +167,7 @@ class NostrServerAuthTest { assertTrue((session.policy as FullAuthPolicy).isAuthenticated()) assertTrue(session.policy.authenticatedUsers.contains(pubkey)) - server.shutdown() + server.close() } @Test @@ -188,7 +188,7 @@ class NostrServerAuthTest { assertTrue(okMessages[0].contains("challenge")) assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) - server.shutdown() + server.close() } @Test @@ -213,7 +213,7 @@ class NostrServerAuthTest { assertTrue(okMessages[0].contains("relay url")) assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) - server.shutdown() + server.close() } @Test @@ -242,7 +242,7 @@ class NostrServerAuthTest { assertTrue(okMessages[0].contains("created_at")) assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) - server.shutdown() + server.close() } @Test @@ -281,7 +281,7 @@ class NostrServerAuthTest { assertTrue(okMessages[0].contains("could not parse message")) assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) - server.shutdown() + server.close() } @Test @@ -315,7 +315,7 @@ class NostrServerAuthTest { assertTrue(authedPubkeys.contains(pubkey)) assertTrue(authedPubkeys.contains(pubkey2)) - server.shutdown() + server.close() } // -- NIP-42: requireAuth --------------------------------------------------- @@ -337,7 +337,7 @@ class NostrServerAuthTest { assertTrue(okMessages[0].contains("\"false\"")) assertTrue(okMessages[0].contains("auth-required:")) - server.shutdown() + server.close() } @Test @@ -354,7 +354,7 @@ class NostrServerAuthTest { assertEquals(1, closedMessages.size) assertTrue(closedMessages[0].contains("auth-required:")) - server.shutdown() + server.close() } @Test @@ -371,7 +371,7 @@ class NostrServerAuthTest { assertEquals(1, closedMessages.size) assertTrue(closedMessages[0].contains("auth-required:")) - server.shutdown() + server.close() } @Test @@ -410,7 +410,7 @@ class NostrServerAuthTest { val eoseMessages = collector.rawMessagesContaining("EOSE") assertTrue(eoseMessages.isNotEmpty()) - server.shutdown() + server.close() } @Test @@ -430,7 +430,7 @@ class NostrServerAuthTest { assertEquals(1, okMessages.size) assertTrue(okMessages[0].contains("\"true\"")) - server.shutdown() + server.close() } // -- Custom AuthPolicy tests ----------------------------------------------- @@ -471,7 +471,7 @@ class NostrServerAuthTest { assertTrue(okMessages[1].contains("\"false\"")) assertTrue(okMessages[1].contains("auth-required:")) - server.shutdown() + server.close() } @Test @@ -531,6 +531,6 @@ class NostrServerAuthTest { assertEquals(1, events.size) assertEquals(pubkey, events[0].event.pubKey) - server.shutdown() + server.close() } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt index 165bfb0e4..3e033cf49 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt @@ -115,7 +115,7 @@ class NostrServerTest { val stored = store.query(Filter(ids = listOf(event.id))) assertEquals(1, stored.size) - server.shutdown() + server.close() } @Test @@ -138,7 +138,7 @@ class NostrServerTest { assertTrue(okMessages[0].contains("\"true\"")) assertTrue(okMessages[1].contains("\"false\"")) - server.shutdown() + server.close() } // -- REQ command ----------------------------------------------------------- @@ -172,7 +172,7 @@ class NostrServerTest { // Events should be newest first assertTrue(events[0].event.createdAt >= events[1].event.createdAt) - server.shutdown() + server.close() } @Test @@ -195,7 +195,7 @@ class NostrServerTest { val events = collector.parsedEventMessages().filterIsInstance() assertEquals(3, events.size) - server.shutdown() + server.close() } // -- Live subscription ----------------------------------------------------- @@ -226,7 +226,7 @@ class NostrServerTest { assertTrue(newMessages.isNotEmpty()) assertTrue(newMessages[0].contains("\"EVENT\"")) - server.shutdown() + server.close() } @Test @@ -251,7 +251,7 @@ class NostrServerTest { assertEquals(countAfterEose, collector1.messages.size) - server.shutdown() + server.close() } // -- CLOSE command --------------------------------------------------------- @@ -282,7 +282,7 @@ class NostrServerTest { assertEquals(countAfterClose, collector1.messages.size) - server.shutdown() + server.close() } @Test @@ -318,7 +318,7 @@ class NostrServerTest { assertTrue(newMessages[0].contains("\"EVENT\"")) assertTrue(newMessages[0].contains(hexId(2))) - server.shutdown() + server.close() } // -- COUNT command (NIP-45) ------------------------------------------------ @@ -344,7 +344,7 @@ class NostrServerTest { assertEquals(1, countMessages.size) assertTrue(countMessages[0].contains("\"count\":2")) - server.shutdown() + server.close() } // -- Disconnect ------------------------------------------------------------ @@ -374,7 +374,7 @@ class NostrServerTest { assertEquals(countAfterDisconnect, collector1.messages.size) assertEquals(2, collector2.messages.size) - server.shutdown() + server.close() } // -- Invalid messages ------------------------------------------------------ @@ -392,6 +392,6 @@ class NostrServerTest { assertEquals(1, collector.messages.size) assertTrue(collector.messages[0].contains("NOTICE")) - server.shutdown() + server.close() } } From 7ea9f72a0acc70fa7642ededc497688fac6e1e6e Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Mon, 30 Mar 2026 12:38:18 +0000 Subject: [PATCH 10/15] New Crowdin translations by GitHub Action --- .../src/main/res/values-hi-rIN/strings.xml | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index c55bc900c..aba13f64b 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -61,6 +61,8 @@ हस्ताक्षरकर्ता ने इस कार्य करने के लिए अपरहस्यीकरण का अधिकार नहीं दिया। आपके हस्ताक्षर क्रमक में निप॰-४४ अपरहस्यीकरण सक्रिय करें तथा पुनः प्रयास करें हस्ताक्षरकर्ता अप्राप्त क्या हस्ताक्षर क्रमक अस्थापित किया गया। जाँच करें कि यदि हस्ताक्षरकर्ता स्थापित है तथा यह लेखा वहाँ है। यदि हस्ताक्षर क्रमक में परिवर्तन है तो निर्गमनांकन तथा प्रवेशांकन पुनः करें। + हस्ताक्षरकर्ता द्वारा दुर्व्यवहार + बाहरी हस्ताक्षरकर्ता द्वारा दिया गया उत्तर विचित्र है अनुरोध के लिए। अमेथिस्ट अथवा हस्ताक्षरकर्ता में कुछ दोष हो सकता है। ज्साप दृष्ट संख्या उद्धृत करें @@ -290,6 +292,7 @@ नोस्ट्र पता कभी नहीं अभी + सेकण्ड घं॰ मि॰ दि॰ @@ -378,12 +381,16 @@ मूलविकल्प स्मर्त्तव्य सूची आपका मूलविकल्प स्मर्त्तव्य सूची जिसका अवलम्बन अनेक ग्राहक करते हैं पाण्डुलिपियाँ + मतदान निजी स्मर्तव्य सार्वजनिक स्मर्तव्य निजी स्मर्तव्य सूची में जोडें सार्वजनिक स्मर्तव्य सूची में जोडें निजी स्मर्तव्य सूची से हटाएँ सार्वजनिक स्मर्तव्य सूची से हटाएँ + टँगे गए पत्र + परिचय के साथ जोडें + परिचय से निकालें स्मर्त्तव्य सूची स्मर्त्तव्य सूची चिह्न नव्य स्मर्त्तव्य सूची @@ -447,6 +454,8 @@ ज्साप अधिकतम सर्वसम्मति (० से १००) प्रतिशत + एक विकल्प + अनेक विकल्प मतदान समापन दिनांक तथा समय मतदान %1$s में समाप्त होगा के पश्चात समाप्त करें @@ -486,6 +495,9 @@ ग्राहक तथा जनता को पता नहीं किसने भुगतान भेजा ज्साप अतिरिक्त नोस्ट्र में कोई पदचिह्न नहीं, केवल लैटनिंग पर + नामरहित + पत्र प्रकाशित करें एक नए क्षेपणार्थ लेखाचिह्न के साथ। आपका लेखा इस उत्तर के साथ नहीं जोडा जाएगा। + यह उत्तर भेजा जाएगा एक नए नामरहित लेखा से अभिलेख सेवासंगणक इस अभिलेख का आरोहण करने के लिए सेवासंगणक का चयन करें लै॰जाल पता अथवा @उपयोगकर्ता @@ -711,6 +723,19 @@ नियम तथा अवस्था अनुपलब्ध अपक्रम तथा सूचनाएँ इस पुनःप्रसारक से + पुनःप्रसारक परिवीक्षक सूचनाएँ + खोलें + पढें + लिखें + आरटीटी॰ + जाल + प्रकार + अवलम्बन किए हुए निप॰ सूची + आवश्यकताएँ + अन्तिम जाँच + %1$d सहस्रतमसेकण्ड + स्वीकृत प्रकार + स्थान सक्रिय ग्राहकताएँ अपूर्ण निर्गतपेटिका घटनाएँ अनुरोध ग्राहकताएँ (%1$d) @@ -1104,6 +1129,13 @@ नया सामुदायिक टीका नया उत्पाद नया स्थान विशेष पत्र + नया लेख + शीर्षक + साराम्श (वैकल्पिक) + बाहरी चित्र जालपता (वैकल्पिक) + आपका लेख मार्कडौन में लिखें… + पूर्वावलोकन + संपादित करें इस पत्र प्रकाशन के सभी प्रतिक्रियाओं को खोलें इस पत्र प्रकाशन के सभी प्रतिक्रियाओं को अवरोधित करें उत्तर @@ -1238,9 +1270,14 @@ गिट क्रमलेखकोश : %1$s जाल : अनुकृति : + स्थिर जालस्थान : %1$s + मूल स्थान + स्रोत : + सेवासंगणक : ओ॰टी॰एस : %1$s समयांकन प्रमाण प्रमाण उपलब्ध है कि इस पत्र पर हस्ताक्षर किया गया %1$s के कुछ पहले। प्रमाण अंकित किया गया द्व्यंकरूप्य खण्डश्रृंखला में उस समय उस दिन पर। + लेख सम्पादित करें पत्र का सम्पादन करें पत्र शोधन के लिए प्रस्ताव परिवर्तनों का साराम्श @@ -1320,6 +1357,7 @@ विषयसूचक समुदाय सूचियाँ + पुनःप्रसारक यन्त्र ताला लगने पर निर्गमनांकन करें निजी सन्देश सार्वजनिक संदेश @@ -1556,6 +1594,7 @@ छोटे ध्वनि सन्देश ध्वनि उत्तर + जाल स्मर्त्तव्यचिह्न विकि॰ एक बढिया सूचनावली के साथ आरम्भ करें उन लोगों का अनुचरण करके जिनको आपके द्वारा विश्वास प्राप्त कोई व्यक्ति करते हैं। अनुचरित सूची आयात @@ -1584,6 +1623,21 @@ सभी चुनें %1$d%% समय निरन्तर उपलब्ध नामरूप्य स्थापना विकल्प + संयोजन परीक्षण + सेवासंगणकों का परीक्षण… + संयोजित + असफल + परीक्षा परिणाम + निदानार्थ लक्षण + अन्तिम परीक्षा + यन्त्र जानकारी + टीएलएस॰ जानकारी + अभी कोई परीक्षा चरण हुआ नहीं + %d सहस्रतमसेकण्ड + क्या सेवासंगणक प्रमाणपत्र पर विश्वास करें। + सेवासंगणक %1$s ने एक प्रमाणपत्र प्रस्तुत किया जो आपके विश्वास भण्डार में अभी नहीं है। सत्यापित करें कि नीचे के हस्ताक्षर वही है जो सेवासंगणक के परिचालक ने प्रकाशित किया है। तत्पश्चात चयन करें कि भविष्य में संयोजनों के लिए विश्वास करना है अथवा नहीं। + विश्वास करें + अस्वीकार करें पुनःप्रसारक समचरणीकरण पुनःप्रसारक समचरणीकरण अपने घटनाओं को पुनःप्रकाशित करें सभी ज्ञात पुनःप्रसारकों तक अपने निर्गतपेटिका आगतपेटिका तथा सीधासन्देश पुनःप्रसारकों का अद्यतन करने के लिए। वैफै॰ आवश्यक। यह सम्भाव्यतः बहुत जानकारी भेज सकता है। @@ -1622,8 +1676,12 @@ कोई घटना नहीं द्व्यंकरूप्य समन्वेषक (ओटीएस॰) घटनाएँ + घटनाएँ आप से + घटनाएँ आप को + खोज साध्य घटनाएँ सीधेसन्देश परिचय + निर्गतपेटिका सूचियाँ पुनःप्रसारक स्थापना विकल्प पूर्व दृष्ट %1$s पहले <%1$s @@ -1664,6 +1722,57 @@ प्रकारों के सत्यापन में निपुण : %1$s इस के लिए साक्षी इस के लिए साक्ष्यांकन अनुरोध + दिनांक सीमा + से + तक + अभी + समस्त काल + अन्तिम समचरणीकरण : %1$s + अन्तिम समचरणीकरण से + जाल स्मर्त्तव्यचिह्न + अभी कोई जाल स्मर्त्तव्यचिह्न नहीं। जोडने के लिए + दबाएँ। + जाल स्मर्त्तव्यचिह्न जोडें + जाल स्मर्त्तव्यचिह्न सम्पादित करें + जालपता + https://example.com + शीर्षक + स्मर्त्तव्यचिह्न शीर्षक + विवरण + एक छोटा विवरण + विषयसूचक (अल्पविराम विभाजित) + नोस्टर, तन्त्रज्ञान, जाललेख + अभिलेखन करें + मिटाएँ + क्या इस जाल स्मर्त्तव्यचिह्न को मिटाएँ। + जालपता खोलें + यह उद्देश्य समाप्त हो चुका है + %1$s वित्तपोषित %2$s साट्स उद्देश्य में से + उद्देश्य संख्या (साट्स) + १००००० + आपके उद्देश्य का विवरण करें + आप किस लिए धनसंग्रह कर रहे हैं। + छोटा साराम्श + संक्षिप्त विवरण पूर्वावलोकनों में दिखाने के लिए + चित्र जालपता (वैकल्पिक) + https://example.com/image.jpg + जालस्थान पता (वैकल्पिक) + https://example.com + समय सीमा (वैकल्पिक) + समय सीमा स्थापित करें + नया उद्देश्य + उद्देश्य बनाएँ + अदृश्य होने के लिए अनुरोध + पुनःप्रसारकों को अनुरोध करें कि आपकी सभी जानकारी मिटाएँ चयनित दिनांक तक। यह कार्य निप॰६२ पर आधारित है तथा कुछ अधिकारक्षेत्रों में न्यायिक रूप से बाध्यकारी है। + एक पुनःप्रसारक का चयन करें + लक्ष्य पुनःप्रसारक + सभी पुनःप्रसारक + यह सभी पुनःप्रसारकों को अनुरोध करेगा कि आपकी कुंचिका से सम्बन्धित सब कुछ मिटा दें चयनित दिनांक तक। इस घटना का यथासम्भाव्य विस्तृत प्रसार किया जाएगा। इसको पूर्ववत नहीं किया जा सकता। + जानकारी मिटाएँ इस तक + इस दिनांक के पूर्व बनाए गए आपके सभी घटनाओं को मिटाने का अनुरोध किया जाएगा चयनित पुनःप्रसारक से। + कारण (विकल्पात्मक) + कारण अथवा न्यायिक संदेश पुनःप्रसारक के परिचालक के लिए + अदृश्यीकरण अनुरोध भेजें + अदृश्यीकरण अनुरोध की पुष्टि करें From f65fd30728af906c10728f11d41de2abed195233 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 12:46:38 +0000 Subject: [PATCH 11/15] docs: simplify RELAY.md by removing verbose sections Remove architecture diagram, NIP support table, module table, Live Subscriptions explanation, serve method details, production-ready example, and excessive code comments. Keep the readable Quick Start, Store options, and Policy documentation in a more concise form. https://claude.ai/code/session_01U3iW3eRD7bfLwrM3L9gkDc --- quartz/RELAY.md | 309 ++---------------------------------------------- 1 file changed, 12 insertions(+), 297 deletions(-) diff --git a/quartz/RELAY.md b/quartz/RELAY.md index dcad2bc25..2532aac38 100644 --- a/quartz/RELAY.md +++ b/quartz/RELAY.md @@ -1,35 +1,18 @@ # Building a Nostr Relay with Quartz -This guide walks you through building a fully functional Nostr relay using Quartz's `NostrServer`, the SQLite `EventStore`, and [Ktor](https://ktor.io/) as the WebSocket transport layer. +Quartz provides a transport-agnostic relay engine. You provide a `send` callback per connection, and it gives you a `RelaySession` that accepts raw JSON strings. Plug it into Ktor or any WebSocket transport. -## Overview - -Quartz provides a complete, transport-agnostic relay engine: - -| Component | Class | Role | -|-----------|-------|------| -| **NostrServer** | `com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer` | Coordinates connections, sessions, and event routing | -| **EventStore** | `com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore` | SQLite-backed persistent event storage | -| **RelaySession** | `com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession` | Manages a single client connection and its subscriptions | -| **IRelayPolicy** | `com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy` | Pluggable access control and validation | - -`NostrServer` doesn't know about HTTP or WebSockets. You provide a `send` callback per connection, and it gives you a `RelaySession` that accepts raw JSON strings. This makes it trivial to plug into Ktor (or any other transport). - -Both `NostrServer` and `EventStore` implement `AutoCloseable`, so you can use Kotlin's `.use {}` for automatic resource cleanup. +Both `NostrServer` and `EventStore` implement `AutoCloseable`. ## Quick Start ### 1. Add Dependencies -In your `build.gradle.kts`: - ```kotlin dependencies { implementation("io.ktor:ktor-server-core:3.1.1") implementation("io.ktor:ktor-server-netty:3.1.1") implementation("io.ktor:ktor-server-websockets:3.1.1") - - // Quartz (use your local module or published artifact) implementation(project(":quartz")) } ``` @@ -37,31 +20,15 @@ dependencies { ### 2. Create the Relay Server ```kotlin -import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer -import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore -import io.ktor.server.application.* -import io.ktor.server.engine.* -import io.ktor.server.netty.* -import io.ktor.server.routing.* -import io.ktor.server.websocket.* -import io.ktor.websocket.* - fun main() { - // 1. Create the event store (SQLite, persisted to disk) - val store = EventStore( - dbName = "relay-events.db", - ) - - // 2. Create the NostrServer with the default VerifyPolicy + val store = EventStore(dbName = "relay-events.db") val server = NostrServer(store) - // 3. Start Ktor with WebSocket support embeddedServer(Netty, port = 7777) { install(WebSockets) routing { webSocket("/") { - // 4. Serve this WebSocket connection (auto-cleanup on disconnect) server.serve( send = { json -> launch { send(Frame.Text(json)) } }, ) { session -> @@ -79,151 +46,59 @@ fun main() { That's it. You now have a NIP-01 compliant relay running on `ws://localhost:7777`. -The `serve` method creates a `RelaySession`, passes it to your block, and automatically closes the session when the block completes (normally or via exception). No `try`/`finally` needed. - -> **Tip:** If you need direct control over the session lifecycle, use `connect()` with `.use {}`: -> ```kotlin -> server.connect { json -> launch { send(Frame.Text(json)) } }.use { session -> -> for (frame in incoming) { -> if (frame is Frame.Text) session.receive(frame.readText()) -> } -> } -> ``` - -## How It Works - -### Connection Lifecycle - -``` -Client connects via WebSocket - │ - ▼ -server.serve(send) { session -> ... } - │ - ▼ - RelaySession created - (policy.onConnect called, e.g. AUTH challenge sent) - │ - ▼ -┌───────────────────────────────┐ -│ For each incoming text frame │ -│ session.receive(jsonString) │──► Parses NIP-01 command -│ │──► Validates via policy -│ │──► Dispatches to handler -└───────────────────────────────┘ - │ - ▼ -Client disconnects → session auto-closed - │ - ▼ - All subscriptions cancelled -``` - -### Supported NIP-01 Commands - -| Client Command | Handler | Description | -|----------------|---------|-------------| -| `["EVENT", ]` | `handleEvent` | Publishes an event. Policy validates, store persists, `OK` response sent. | -| `["REQ", , ...]` | `handleReq` | Opens a subscription. Returns matching historical events, `EOSE`, then streams live matches. | -| `["CLOSE", ]` | `handleClose` | Cancels a subscription. | -| `["COUNT", , ...]` | `handleCount` | Returns the count of matching events (NIP-45). | -| `["AUTH", ]` | `handleAuth` | Authenticates the client (NIP-42). | - -### Live Subscriptions - -When a client sends `REQ`, the relay: -1. Queries the `EventStore` for all matching historical events -2. Sends each as an `EVENT` message -3. Sends `EOSE` (End of Stored Events) -4. Keeps the subscription open - any new event inserted by *any* client that matches the filter is automatically pushed - -This is handled by `LiveEventStore`, which wraps the store with a `SharedFlow` that broadcasts new events to all active subscriptions. - ## Event Store ### SQLite (Persistent) ```kotlin -val store = EventStore( - dbName = "relay-events.db", -) +val store = EventStore(dbName = "relay-events.db") ``` -The SQLite store uses `androidx.sqlite` with the bundled driver (no native SQLite dependency needed). It features: - -- **WAL journal mode** for concurrent reads/writes -- **32 MB memory cache** for fast queries -- **Modular architecture** with pluggable processing modules: - -| Module | NIP | Purpose | -|--------|-----|---------| -| `SeedModule` | NIP-01 | Core event storage and retrieval | -| `EventIndexesModule` | NIP-01 | Indexes for efficient filtering | -| `ReplaceableModule` | NIP-16 | Replaces older versions of replaceable events | -| `AddressableModule` | NIP-33 | Handles parameterized replaceable events | -| `EphemeralModule` | NIP-01 | Rejects ephemeral events from persistence | -| `DeletionRequestModule` | NIP-09 | Processes deletion requests | -| `ExpirationModule` | NIP-40 | Handles event expiration timestamps | -| `RightToVanishModule` | — | Cleans up ephemeral data | -| `FullTextSearchModule` | NIP-50 | Full-text search on event content | +Uses `androidx.sqlite` with WAL journal mode and a 32 MB memory cache. ### In-Memory (Testing) -Pass `null` as the database name for a purely in-memory store (no disk I/O): - ```kotlin val store = EventStore(null) ``` ### Indexing Strategy -Control which indexes are created via `IndexingStrategy`: +Control which indexes are created: ```kotlin val store = EventStore( dbName = "relay-events.db", indexStrategy = DefaultIndexingStrategy( - // Enable if you receive many filter-by-time-only queries indexEventsByCreatedAtAlone = false, - // Enable if you receive many tag-only queries without kind indexTagsByCreatedAtAlone = false, - // Enable for queries combining tags + kind + author indexTagsWithKindAndPubkey = false, - // Enable for spec-compliant ordering when created_at collides useAndIndexIdOnOrderBy = false, ), ) ``` -By default, all single-letter tags with values are indexed (e.g., `#e`, `#p`, `#t`). Override `shouldIndex(kind, tag)` for custom behavior. - -> **Tip:** More indexes = faster queries but larger database. Only enable what your relay's query patterns actually need. +By default, all single-letter tags with values are indexed. Override `shouldIndex(kind, tag)` for custom behavior. More indexes = faster queries but larger database. ## Policies -Policies control what clients can do. They validate commands and can rewrite filters before execution. +Policies control what clients can do. They validate commands and can rewrite filters. ### Built-in Policies -#### `VerifyPolicy` (default) - -Verifies event signatures and IDs. Rejects malformed events. Allows all `REQ` and `COUNT` commands. +**`VerifyPolicy`** (default) — Verifies event signatures and IDs. Rejects malformed events. ```kotlin val server = NostrServer(store) // Uses VerifyPolicy by default ``` -#### `EmptyPolicy` - -Accepts everything without any validation. Useful for testing or trusted environments. +**`EmptyPolicy`** — Accepts everything. Useful for testing. ```kotlin val server = NostrServer(store, policyBuilder = { EmptyPolicy }) ``` -#### `FullAuthPolicy` - -Requires NIP-42 authentication before accepting any `EVENT`, `REQ`, or `COUNT` commands. Sends an `AUTH` challenge on connect. +**`FullAuthPolicy`** — Requires NIP-42 authentication before accepting any command. ```kotlin val server = NostrServer( @@ -234,36 +109,17 @@ val server = NostrServer( ) ``` -Validates: -- Challenge string matches -- Relay URL matches -- Timestamp is within 10 minutes -- Event signature is valid - ### Composing Policies -Chain multiple policies using the `+` operator or `PolicyStack`. All must approve; the first rejection wins. Policies run in order and can rewrite commands for downstream policies. +Chain policies with `+` or `PolicyStack`. All must approve; first rejection wins. ```kotlin -// Using the + operator val server = NostrServer( store = store, policyBuilder = { VerifyPolicy + FullAuthPolicy(relay = "wss://myrelay.example.com/".normalizeRelayUrl()!!) }, ) - -// Or using PolicyStack for three or more policies -val server = NostrServer( - store = store, - policyBuilder = { - PolicyStack( - VerifyPolicy, - FullAuthPolicy(relay = "wss://myrelay.example.com/".normalizeRelayUrl()!!), - MyCustomRateLimitPolicy(), - ) - }, -) ``` ### Writing a Custom Policy @@ -271,9 +127,6 @@ val server = NostrServer( Implement `IRelayPolicy`: ```kotlin -import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy -import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult - class KindWhitelistPolicy( private val allowedKinds: Set, ) : IRelayPolicy { @@ -304,123 +157,9 @@ val server = NostrServer( ) ``` -**Policy interface methods:** - -| Method | When Called | Return | -|--------|------------|--------| -| `onConnect(send)` | New client connects | Send welcome messages (e.g., AUTH challenge) | -| `accept(EventCmd)` | Client publishes an event | `Accepted` or `Rejected("reason")` | -| `accept(ReqCmd)` | Client opens a subscription | `Accepted` (may rewrite filters) or `Rejected` | -| `accept(CountCmd)` | Client requests a count | `Accepted` or `Rejected` | -| `accept(AuthCmd)` | Client authenticates | `Accepted` or `Rejected` | -| `canSendToSession(event)` | Live event about to be forwarded | `true` to deliver, `false` to suppress | - -## Full Example: Production-Ready Relay - -```kotlin -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl -import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer -import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy -import com.vitorpamplona.quartz.nip01Core.store.sqlite.DefaultIndexingStrategy -import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore -import io.ktor.server.application.* -import io.ktor.server.engine.* -import io.ktor.server.netty.* -import io.ktor.server.routing.* -import io.ktor.server.websocket.* -import io.ktor.websocket.* -import kotlinx.coroutines.launch - -fun main() { - val store = EventStore( - dbName = "relay-events.db", - relay = "wss://myrelay.example.com/".normalizeRelayUrl(), - indexStrategy = DefaultIndexingStrategy( - indexEventsByCreatedAtAlone = true, - ), - ) - - // NostrServer implements AutoCloseable — .use {} ensures clean shutdown - NostrServer( - store = store, - policyBuilder = { VerifyPolicy }, - ).use { server -> - embeddedServer(Netty, port = 7777) { - install(WebSockets) { - pingPeriodMillis = 30_000 - timeoutMillis = 60_000 - maxFrameSize = Long.MAX_VALUE - } - - routing { - webSocket("/") { - server.serve( - send = { json -> launch { send(Frame.Text(json)) } }, - ) { session -> - for (frame in incoming) { - if (frame is Frame.Text) { - session.receive(frame.readText()) - } - } - } - } - } - }.start(wait = true) - } -} -``` - -## Architecture Diagram - -``` -┌─────────────────────────────────────────────────┐ -│ Ktor Server │ -│ │ -│ WebSocket("/") │ -│ │ │ -│ ▼ │ -│ ┌────────────────────────────────────────┐ │ -│ │ NostrServer │ │ -│ │ │ │ -│ │ serve(send) { session -> ... } │ │ -│ │ │ │ │ -│ │ ├─ IRelayPolicy │ │ -│ │ │ (validate) │ │ -│ │ │ │ │ -│ │ ├─ LiveEventStore │ │ -│ │ │ (query + live) │ │ -│ │ │ │ │ -│ │ └─ Subscriptions │ │ -│ │ (per client) │ │ -│ └────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌────────────────────────────────────────┐ │ -│ │ EventStore (SQLite) │ │ -│ │ │ │ -│ │ insert / query / count / delete │ │ -│ │ │ │ -│ │ Modules: │ │ -│ │ Seed ─ Replaceable ─ Addressable │ │ -│ │ Deletion ─ Expiration ─ FTS │ │ -│ └────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────┘ -``` - ## Testing -Use the in-memory store and `UnconfinedTestDispatcher` for deterministic tests: - ```kotlin -import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer -import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy -import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertTrue - @OptIn(ExperimentalCoroutinesApi::class) class MyRelayTest { @@ -433,20 +172,13 @@ class MyRelayTest { policyBuilder = { EmptyPolicy }, parentContext = dispatcher, ).use { server -> - // Collect messages sent to this client val messages = mutableListOf() val session = server.connect { messages.add(it) } - // Publish an event session.receive("""["EVENT",{"id":"${"0".repeat(64)}","pubkey":"${"a".repeat(64)}","created_at":1000,"kind":1,"tags":[],"content":"hello","sig":"${"b".repeat(128)}"}]""") - - // Verify OK response assertTrue(messages.any { it.contains("OK") }) - // Subscribe to kind 1 session.receive("""["REQ","sub1",{"kinds":[1]}]""") - - // Verify we get the event back + EOSE assertTrue(messages.any { it.contains("EVENT") }) assertTrue(messages.any { it.contains("EOSE") }) } @@ -454,25 +186,8 @@ class MyRelayTest { } ``` -## NIP Support - -The relay engine and SQLite store support the following NIPs out of the box: - -| NIP | Feature | Component | -|-----|---------|-----------| -| NIP-01 | Basic protocol (EVENT, REQ, CLOSE, EOSE, OK, NOTICE) | `NostrServer`, `RelaySession` | -| NIP-09 | Event deletion | `DeletionRequestModule` | -| NIP-16 | Replaceable events | `ReplaceableModule` | -| NIP-33 | Parameterized replaceable events | `AddressableModule` | -| NIP-40 | Expiration timestamp | `ExpirationModule` | -| NIP-42 | Authentication | `FullAuthPolicy` | -| NIP-45 | Event counts | `RelaySession.handleCount` | -| NIP-50 | Search | `FullTextSearchModule` | - ## Key Source Files -All relay infrastructure lives in the `quartz` module: - ``` quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/ ├── relay/server/ From 4581233c105d5ac6a00ba63e04c5e03d5e103536 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Mon, 30 Mar 2026 12:50:10 +0000 Subject: [PATCH 12/15] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-hi-rIN/strings.xml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index aba13f64b..896541d7a 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -1774,5 +1774,13 @@ कारण अथवा न्यायिक संदेश पुनःप्रसारक के परिचालक के लिए अदृश्यीकरण अनुरोध भेजें अदृश्यीकरण अनुरोध की पुष्टि करें + आप %1$s को अनुरोध करनेवाले हैं कि स्थायी रूप से चयनित दिनांक के पूर्व के आपकी सभी जानकारी मिटा दें। इसे पूर्ववत नहीं किया जा सकता। + आप ॰सभी॰ पुनःप्रसारकों को अनुरोध करनेवाले हैं कि स्थायी रूप से चयनित दिनांक के पूर्व के आपकी सभी जानकारी मिटा दें। इसे सर्वत्र प्रसारित किया जाएगा तथा पूर्ववत नहीं किया जा सकता। + अदृश्यीकरण अनुरोध भेजा गया + दिनांक का चयन करें + समय का चयन करें + अदृश्यीकरण इतिहास + नवीकरण + ये आपके पूर्वकाल के अदृश्यीकरण अनुरोध घटनाएँ है जो संयोजित पुनःप्रसारकों में से प्राप्त हुए। इन घटनाओं में सूचित पुनःप्रसारकों में घटना दिनांक के पूर्व की आपकी कोई जानकारी नहीं रखनी चाहिए। From 3d0364ec5e1ae3c61acf64592e2a2c23b224007c Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 30 Mar 2026 17:09:39 +0200 Subject: [PATCH 13/15] update translations: CZ, DE, PT, SE --- .../src/main/res/values-cs-rCZ/strings.xml | 23 +++++++++++++++++++ .../src/main/res/values-de-rDE/strings.xml | 23 +++++++++++++++++++ .../src/main/res/values-pt-rBR/strings.xml | 23 +++++++++++++++++++ .../src/main/res/values-sv-rSE/strings.xml | 23 +++++++++++++++++++ 4 files changed, 92 insertions(+) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 6f60b8975..6bd227416 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -1825,4 +1825,27 @@ URL ikony relé Spravovat relé Spravovat + Kořenový web + Servery: + Zdroj: + Statický web: %1$s + Přijímané druhy + Poloha + Žádost o připojení k relé + Žádost o opuštění relé + Člen přidán do relé + Člen odebrán z relé + Členové + %1$d členů přidáno do relé + %1$d členů + Žádní členové nenalezeni + Žádost o připojení odeslána + Žádost o opuštění odeslána + Načítání členů… + %1$d členů odebráno z relé + Požádat o připojení + Požádat o opuštění + Členové %1$s + Jste členem + Seznam členů relé diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 693a7a64f..a4ef580d1 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -1830,4 +1830,27 @@ anz der Bedingungen ist erforderlich Relay-Symbol-URL Relay verwalten Verwalten + Stammseite + Server: + Quelle: + Statische Website: %1$s + Akzeptierte Arten + Standort + Relay-Beitrittsanfrage + Relay-Austrittsanfrage + Mitglied zum Relay hinzugefügt + Mitglied aus Relay entfernt + Mitglieder + %1$d Mitglieder zum Relay hinzugefügt + %1$d Mitglieder + Keine Mitglieder gefunden + Beitrittsanfrage gesendet + Austrittsanfrage gesendet + Mitglieder werden geladen… + %1$d Mitglieder aus Relay entfernt + Beitritt anfragen + Austritt anfragen + Mitglieder von %1$s + Du bist Mitglied + Relay-Mitgliederliste diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 61625cac7..02773d66f 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -1825,4 +1825,27 @@ URL do Ícone do Relay Gerenciar Relay Gerenciar + Site Raiz + Servidores: + Fonte: + Site Estático: %1$s + Tipos Aceitos + Localização + Solicitação de entrada no relay + Solicitação de saída do relay + Membro adicionado ao relay + Membro removido do relay + Membros + %1$d membros adicionados ao relay + %1$d membros + Nenhum membro encontrado + Solicitação de entrada enviada + Solicitação de saída enviada + Carregando membros… + %1$d membros removidos do relay + Solicitar Entrada + Solicitar Saída + Membros de %1$s + Você é membro + Lista de membros do relay diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 78c0d5f2f..e42945a61 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -1824,4 +1824,27 @@ URL för reläikon Hantera relä Hantera + Webbplats rot + Servrar: + Källa: + Statisk webbplats: %1$s + Accepterade typer + Plats + Begäran om att gå med i relä + Begäran om att lämna relä + Medlem tillagd i relä + Medlem borttagen från relä + Medlemmar + %1$d medlemmar tillagda i relä + %1$d medlemmar + Inga medlemmar hittades + Begäran om att gå med skickad + Begäran om att lämna skickad + Laddar medlemmar… + %1$d medlemmar borttagna från relä + Begär att gå med + Begär att lämna + Medlemmar i %1$s + Du är medlem + Relämedlemslista From 6912a120f8caccba371ad568b97a9e2ce9087fd4 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Mon, 30 Mar 2026 15:12:36 +0000 Subject: [PATCH 14/15] New Crowdin translations by GitHub Action --- .../src/main/res/values-cs-rCZ/strings.xml | 32 ++++----- .../src/main/res/values-de-rDE/strings.xml | 32 ++++----- .../src/main/res/values-hi-rIN/strings.xml | 70 +++++++++++++++++++ .../src/main/res/values-hu-rHU/strings.xml | 21 ++++++ .../src/main/res/values-pt-rBR/strings.xml | 32 ++++----- .../src/main/res/values-sv-rSE/strings.xml | 32 ++++----- .../src/main/res/values-zh-rCN/strings.xml | 21 ++++++ 7 files changed, 176 insertions(+), 64 deletions(-) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 6bd227416..509869ff3 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -730,6 +730,8 @@ Požadavky Poslední kontrola %1$d ms + Přijímané druhy + Poloha Aktivní odběry Čekající události k odeslání REQ odběry (%1$d) @@ -1263,6 +1265,10 @@ Git repositář: %1$s Internet: Klon: + Statický web: %1$s + Kořenový web + Zdroj: + Servery: OTS: %1$s Důkaz časového razítka Existuje důkaz, že tento příspěvek byl podepsán někdy před %1$s. Důkaz byl označen v Bitcoin blockchainu v tomto datu a čase. @@ -1825,27 +1831,21 @@ URL ikony relé Spravovat relé Spravovat - Kořenový web - Servery: - Zdroj: - Statický web: %1$s - Přijímané druhy - Poloha - Žádost o připojení k relé - Žádost o opuštění relé - Člen přidán do relé - Člen odebrán z relé Členové - %1$d členů přidáno do relé + Členové %1$s %1$d členů - Žádní členové nenalezeni - Žádost o připojení odeslána - Žádost o opuštění odeslána Načítání členů… - %1$d členů odebráno z relé + Žádní členové nenalezeni Požádat o připojení Požádat o opuštění - Členové %1$s + Žádost o připojení odeslána + Žádost o opuštění odeslána Jste členem Seznam členů relé + Člen přidán do relé + %1$d členů přidáno do relé + Člen odebrán z relé + %1$d členů odebráno z relé + Žádost o připojení k relé + Žádost o opuštění relé diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index a4ef580d1..ad410c999 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -735,6 +735,8 @@ anz der Bedingungen ist erforderlich Anforderungen Letzte Prüfung %1$d ms + Akzeptierte Arten + Standort Aktive Abonnements Ausstehende Outbox-Ereignisse REQ-Abonnements (%1$d) @@ -1268,6 +1270,10 @@ anz der Bedingungen ist erforderlich Git Repository: %1$s Internet: Klonen: + Statische Website: %1$s + Stammseite + Quelle: + Server: OTS: %1$s Zeitstempel Beweis Es gibt einen Beweis, dass dieser Beitrag irgendwann vor %1$s signiert wurde. Der Beweis wurde zu diesem Datum und Uhrzeit in der Bitcoin-Blockchain gestempelt. @@ -1830,27 +1836,21 @@ anz der Bedingungen ist erforderlich Relay-Symbol-URL Relay verwalten Verwalten - Stammseite - Server: - Quelle: - Statische Website: %1$s - Akzeptierte Arten - Standort - Relay-Beitrittsanfrage - Relay-Austrittsanfrage - Mitglied zum Relay hinzugefügt - Mitglied aus Relay entfernt Mitglieder - %1$d Mitglieder zum Relay hinzugefügt + Mitglieder von %1$s %1$d Mitglieder - Keine Mitglieder gefunden - Beitrittsanfrage gesendet - Austrittsanfrage gesendet Mitglieder werden geladen… - %1$d Mitglieder aus Relay entfernt + Keine Mitglieder gefunden Beitritt anfragen Austritt anfragen - Mitglieder von %1$s + Beitrittsanfrage gesendet + Austrittsanfrage gesendet Du bist Mitglied Relay-Mitgliederliste + Mitglied zum Relay hinzugefügt + %1$d Mitglieder zum Relay hinzugefügt + Mitglied aus Relay entfernt + %1$d Mitglieder aus Relay entfernt + Relay-Beitrittsanfrage + Relay-Austrittsanfrage diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 896541d7a..166a4da5b 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -1782,5 +1782,75 @@ अदृश्यीकरण इतिहास नवीकरण ये आपके पूर्वकाल के अदृश्यीकरण अनुरोध घटनाएँ है जो संयोजित पुनःप्रसारकों में से प्राप्त हुए। इन घटनाओं में सूचित पुनःप्रसारकों में घटना दिनांक के पूर्व की आपकी कोई जानकारी नहीं रखनी चाहिए। + कोई अदृश्यीकरण अनुरोध प्राप्त नहीं + आपने कोई अदृश्यीकरण अनुरोध अभी तक भेजे नहीं। + लक्ष्य पुनःप्रसारक + यह अनुरोध सभी पुनःप्रसारकों को लक्ष्यांकित करता है। अदृश्यीकरण अनुरोध पटल का उपयोग करें विशिष्ट पुनःप्रसारकों की अनुपालन परीक्षा करने के लिए। + परीक्षण + अनुपालक + अनुपालक नहीं + अपक्रम + अदृश्यीकरण इतिहास + प्रबन्धन %1$s + पुनःप्रसारक प्रबन्धन क्षमताओं का आवहन… + पुनःप्रसारक प्रबन्धक के साथ जुडने में अक्षम + कोई प्रबन्धक विधि उपलब्ध नहीं + हटाएँ + प्रयोक्ता सूची + घटनाएँ + प्रकार + अंकीय जालपताएँ + स्थापना विकल्प + प्रतिबन्धित प्रयोक्ता + कोई प्रतिबन्धित प्रयोक्ता नहीं + अनुमत प्रयोक्ता + कोई अनुमत प्रयोक्ता नहीं + नियमन पंक्ति + नियमनार्थ कोई घटनाएँ नहीं + प्रतिबन्धित घटनाएँ + कोई प्रतिबन्धित घटनाएँ नहीं + अनुमत प्रकार + कोई अनुमत प्रकार नहीं + अवरोधित अंकीय जालपताएँ + कोई अवरोधित अंकीय जालपताएँ नहीं + जोडें + हटाएँ + अनुमत + प्रतिबन्ध + ख्याप्य कुंचिका प्रतिबन्धित करें + ख्याप्य कुंचिका को अनुमति दें + घटना प्रतिबन्धित करें + प्रकार को अनुमति दें + अंकीय जालपता अवरोधित करें + ख्याप्य कुंचिका (षोडशांक) + घटना विभेदक (षोडशांक) + प्रकार संख्या + अंकीय जालपता + कारण (विकल्पात्मक) + पुष्टि करें + निरस्त करें + लागू करें + पुनःप्रसारक नाम + पुनःप्रसारक विवरण + पनःप्रसारक चिह्न जालपता + पुनःप्रसारक प्रबन्धन + प्रबन्धन + सदस्य सूची + %1$s के सदस्य + %1$d सदस्य + सदस्य आवहन… + कोई सदस्य प्राप्त नहीं + जुडने का अनुरोध + चले जाने का अनुरोध + जुडने का अनुरोध भेजा गया + चले जाने का अनुरोध भेजा गया + आप एक सदस्य हैं + पुनःप्रसारक सदस्यता सूची + सदस्य जोडा गया पुनःप्रसारक के साथ + %1$d सदस्य जोडे गये पुनःप्रसारक के साथ + सदस्य हटाया गया पुनःप्रसारक से + %1$d सदस्य हटाए गए पुनःप्रसारक से + पुनःप्रसारक से जुडने का अनुरोध + पुनःप्रसारक से चले जाने का अनुरोध diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index 6abe741fd..6589c546a 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -1270,6 +1270,10 @@ Git tároló: %1$s Weboldal: Klónozás: + Statikus weboldal: %1$s + Főoldal + Forrás: + Kiszolgálók: OTS: %1$s Időbélyeg-igazolás Bizonyíték van arra, hogy ezt a bejegyzést valamikor %1$s előtt írták alá. A bizonyítékot ezen a napon és időpontban bélyegezték a Bitcoin blokkláncába. @@ -1832,4 +1836,21 @@ Átjátszó ikonjának webcíme Átjátszó kezelése Kezelés + Tagok + A(z) %1$s tagjai + %1$d tag + Tagok betöltése… + Nem találhatók tagok + Csatlakozási kérés + Elhagyási kérés + Csatlakozási kérés elküldve + Elhagyási kérés elküldve + Ön tagja az átjátszónak + Átjátszó tagsági listája + A tag hozzá lett adva az átjátszóhoz + %1$d tag hozzáadva az átjátszóhoz + A tag el lett távolítva az átjátszóról + %1$d tag el lett távolítva az átjátszóról + Átjátszóhoz való csatlakozás kérése + Átjátszó elhagyásának kérése diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 02773d66f..a578b8962 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -730,6 +730,8 @@ Requisitos Última verificação %1$d ms + Tipos Aceitos + Localização Assinaturas Ativas Eventos Pendentes na Outbox Assinaturas REQ (%1$d) @@ -1263,6 +1265,10 @@ Repositório Git: %1$s Site: Clonar: + Site Estático: %1$s + Site Raiz + Fonte: + Servidores: OTS: %1$s Prova de Carimbo de data/hora Há prova de que esta postagem foi assinada antes de %1$s. A prova foi carimbada no blockchain do Bitcoin naquela data e hora. @@ -1825,27 +1831,21 @@ URL do Ícone do Relay Gerenciar Relay Gerenciar - Site Raiz - Servidores: - Fonte: - Site Estático: %1$s - Tipos Aceitos - Localização - Solicitação de entrada no relay - Solicitação de saída do relay - Membro adicionado ao relay - Membro removido do relay Membros - %1$d membros adicionados ao relay + Membros de %1$s %1$d membros - Nenhum membro encontrado - Solicitação de entrada enviada - Solicitação de saída enviada Carregando membros… - %1$d membros removidos do relay + Nenhum membro encontrado Solicitar Entrada Solicitar Saída - Membros de %1$s + Solicitação de entrada enviada + Solicitação de saída enviada Você é membro Lista de membros do relay + Membro adicionado ao relay + %1$d membros adicionados ao relay + Membro removido do relay + %1$d membros removidos do relay + Solicitação de entrada no relay + Solicitação de saída do relay diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index e42945a61..88be9e2d2 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -729,6 +729,8 @@ Krav Senaste kontroll %1$d ms + Accepterade typer + Plats Aktiva prenumerationer Väntande utgående händelser REQ-prenumerationer (%1$d) @@ -1262,6 +1264,10 @@ Git Repository: %1$s Webbplats: Klona: + Statisk webbplats: %1$s + Webbplats rot + Källa: + Servrar: OTS: %1$s Tidsstämpel Bevis Det finns bevis på att detta inlägg signerades någon gång före %1$s. Beviset stämplades i Bitcoin-blockchainen vid det datumet och den tiden. @@ -1824,27 +1830,21 @@ URL för reläikon Hantera relä Hantera - Webbplats rot - Servrar: - Källa: - Statisk webbplats: %1$s - Accepterade typer - Plats - Begäran om att gå med i relä - Begäran om att lämna relä - Medlem tillagd i relä - Medlem borttagen från relä Medlemmar - %1$d medlemmar tillagda i relä + Medlemmar i %1$s %1$d medlemmar - Inga medlemmar hittades - Begäran om att gå med skickad - Begäran om att lämna skickad Laddar medlemmar… - %1$d medlemmar borttagna från relä + Inga medlemmar hittades Begär att gå med Begär att lämna - Medlemmar i %1$s + Begäran om att gå med skickad + Begäran om att lämna skickad Du är medlem Relämedlemslista + Medlem tillagd i relä + %1$d medlemmar tillagda i relä + Medlem borttagen från relä + %1$d medlemmar borttagna från relä + Begäran om att gå med i relä + Begäran om att lämna relä diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 4d62b33c3..ac2075f8a 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -1270,6 +1270,10 @@ Git 仓库: %1$s 网址 克隆: + 静态网站: %1$s + 根站点 + 源: + 服务器: OTS:%1$s OpenTimestamps 证明 %1$s之前的某个时候签署了此帖子的证明。此证明是在那个日期和时间在比特币区块链中盖章的。 @@ -1832,4 +1836,21 @@ 中继图标 URL 管理中继 管理 + 成员 + %1$s 的成员 + %1$d 位成员 + 加载成员… + 未找到成员 + 申请加入 + 请求退出 + 加入请求已发送 + 已发送离开请求 + 您是成员 + 中继成员列表 + 已添加成员到中继 + 添加了 %1$d 位成员到中继 + 从中继删除了成员 + 从中继删除了 %1$d 位成员 + 中继加入请求 + 中继离开请求 From 06fae3ba31787efb20538751d1afc3ffd17c07fc Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 30 Mar 2026 17:24:20 +0200 Subject: [PATCH 15/15] remove unused imports --- .../subscriptions/DesktopRelaySubscriptionsCoordinator.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt index 9f1116cfe..b8fe4a15f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt @@ -29,7 +29,6 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -219,7 +218,7 @@ class DesktopRelaySubscriptionsCoordinator( filters = indexRelays.associateWith { listOf( - com.vitorpamplona.quartz.nip01Core.relay.filters.Filter( + Filter( kinds = listOf(com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent.KIND), authors = listOf(pubkey), limit = 1,