reuse some filtering logic

This commit is contained in:
nrobi144
2026-01-03 06:15:37 +02:00
parent 36bb89fd36
commit f800e20b05
11 changed files with 1427 additions and 320 deletions
@@ -0,0 +1,355 @@
/**
* 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.filters
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
/**
* Type-safe builders for common Nostr filter patterns.
* Provides convenience functions for creating relay subscription filters.
*/
object FilterBuilders {
/**
* Creates a filter for text notes (kind 1) from all authors.
*
* @param limit Maximum number of events to request
* @param since Timestamp for events with publication time ≥ this value
* @param until Timestamp for events with publication time ≤ this value
* @return Filter for global text notes
*/
fun textNotesGlobal(
limit: Int? = null,
since: Long? = null,
until: Long? = null,
): Filter =
Filter(
kinds = listOf(1), // TextNoteEvent.KIND
limit = limit,
since = since,
until = until,
)
/**
* Creates a filter for text notes (kind 1) from specific authors.
*
* @param authors List of author public keys (hex-encoded, 64 chars each)
* @param limit Maximum number of events to request
* @param since Timestamp for events with publication time ≥ this value
* @param until Timestamp for events with publication time ≤ this value
* @return Filter for text notes from specified authors
*/
fun textNotesFromAuthors(
authors: List<String>,
limit: Int? = null,
since: Long? = null,
until: Long? = null,
): Filter =
Filter(
kinds = listOf(1), // TextNoteEvent.KIND
authors = authors,
limit = limit,
since = since,
until = until,
)
/**
* Creates a filter for user metadata (kind 0) from a specific author.
*
* @param pubKeyHex Author public key (hex-encoded, 64 chars)
* @return Filter for user metadata (limit=1 since only latest is needed)
*/
fun userMetadata(pubKeyHex: String): Filter =
Filter(
kinds = listOf(0), // MetadataEvent.KIND
authors = listOf(pubKeyHex),
limit = 1,
)
/**
* Creates a filter for contact list (kind 3) from a specific author.
*
* @param pubKeyHex Author public key (hex-encoded, 64 chars)
* @return Filter for contact list (limit=1 since only latest is needed)
*/
fun contactList(pubKeyHex: String): Filter =
Filter(
kinds = listOf(3), // ContactListEvent.KIND
authors = listOf(pubKeyHex),
limit = 1,
)
/**
* Creates a filter for notifications (mentions, replies, reactions, reposts, zaps) for a user.
*
* Includes:
* - kind 1 (text notes mentioning user)
* - kind 7 (reactions)
* - kind 6 (reposts)
* - kind 16 (generic reposts)
* - kind 9735 (zaps)
*
* @param pubKeyHex User public key (hex-encoded, 64 chars) to filter notifications for
* @param limit Maximum number of events to request
* @param since Timestamp for events with publication time ≥ this value
* @return Filter for notifications targeting the specified user
*/
fun notificationsForUser(
pubKeyHex: String,
limit: Int? = null,
since: Long? = null,
): Filter =
Filter(
kinds =
listOf(
1, // TextNoteEvent.KIND (mentions/replies)
7, // ReactionEvent.KIND
6, // RepostEvent.KIND
16, // GenericRepostEvent.KIND
9735, // LnZapEvent.KIND
),
tags = mapOf("p" to listOf(pubKeyHex)),
limit = limit,
since = since,
)
/**
* Creates a filter for specific event kinds.
*
* @param kinds List of event kinds to filter
* @param limit Maximum number of events to request
* @param since Timestamp for events with publication time ≥ this value
* @param until Timestamp for events with publication time ≤ this value
* @return Filter for specified event kinds
*/
fun byKinds(
kinds: List<Int>,
limit: Int? = null,
since: Long? = null,
until: Long? = null,
): Filter =
Filter(
kinds = kinds,
limit = limit,
since = since,
until = until,
)
/**
* Creates a filter for events from specific authors.
*
* @param authors List of author public keys (hex-encoded, 64 chars each)
* @param kinds Optional list of event kinds to filter (if null, all kinds)
* @param limit Maximum number of events to request
* @param since Timestamp for events with publication time ≥ this value
* @param until Timestamp for events with publication time ≤ this value
* @return Filter for events from specified authors
*/
fun byAuthors(
authors: List<String>,
kinds: List<Int>? = null,
limit: Int? = null,
since: Long? = null,
until: Long? = null,
): Filter =
Filter(
authors = authors,
kinds = kinds,
limit = limit,
since = since,
until = until,
)
/**
* Creates a filter for events with specific event IDs.
*
* @param ids List of event IDs (hex-encoded, 64 chars each)
* @return Filter for specified event IDs
*/
fun byIds(ids: List<String>): Filter =
Filter(
ids = ids,
)
/**
* Creates a filter for events tagged with specific p-tags (mentioning users).
*
* @param pubKeys List of public keys (hex-encoded, 64 chars each) to filter by
* @param kinds Optional list of event kinds to filter
* @param limit Maximum number of events to request
* @param since Timestamp for events with publication time ≥ this value
* @return Filter for events mentioning specified users
*/
fun byPTags(
pubKeys: List<String>,
kinds: List<Int>? = null,
limit: Int? = null,
since: Long? = null,
): Filter =
Filter(
tags = mapOf("p" to pubKeys),
kinds = kinds,
limit = limit,
since = since,
)
/**
* Creates a filter for events tagged with specific e-tags (referencing events).
*
* @param eventIds List of event IDs (hex-encoded, 64 chars each) to filter by
* @param kinds Optional list of event kinds to filter
* @param limit Maximum number of events to request
* @param since Timestamp for events with publication time ≥ this value
* @return Filter for events referencing specified events
*/
fun byETags(
eventIds: List<String>,
kinds: List<Int>? = null,
limit: Int? = null,
since: Long? = null,
): Filter =
Filter(
tags = mapOf("e" to eventIds),
kinds = kinds,
limit = limit,
since = since,
)
/**
* Creates a filter for events with custom tag filters.
*
* @param tags Map of tag names to value lists (e.g., {"p": ["pubkey1"], "t": ["bitcoin"]})
* @param kinds Optional list of event kinds to filter
* @param limit Maximum number of events to request
* @param since Timestamp for events with publication time ≥ this value
* @param until Timestamp for events with publication time ≤ this value
* @return Filter with custom tag criteria
*/
fun byTags(
tags: Map<String, List<String>>,
kinds: List<Int>? = null,
limit: Int? = null,
since: Long? = null,
until: Long? = null,
): Filter =
Filter(
tags = tags,
kinds = kinds,
limit = limit,
since = since,
until = until,
)
}
/**
* DSL builder for creating custom filters with a fluent API.
*
* Example:
* ```kotlin
* val filter = buildFilter {
* kinds(1, 7)
* authors("pubkey1", "pubkey2")
* limit(50)
* since(System.currentTimeMillis() / 1000 - 86400) // Last 24 hours
* }
* ```
*/
class FilterBuilder {
private var ids: List<String>? = null
private var authors: List<String>? = null
private var kinds: List<Int>? = null
private var tags: MutableMap<String, List<String>>? = null
private var since: Long? = null
private var until: Long? = null
private var limit: Int? = null
private var search: String? = null
fun ids(vararg ids: String) {
this.ids = ids.toList()
}
fun ids(ids: List<String>) {
this.ids = ids
}
fun authors(vararg authors: String) {
this.authors = authors.toList()
}
fun authors(authors: List<String>) {
this.authors = authors
}
fun kinds(vararg kinds: Int) {
this.kinds = kinds.toList()
}
fun kinds(kinds: List<Int>) {
this.kinds = kinds
}
fun tag(
name: String,
values: List<String>,
) {
if (tags == null) tags = mutableMapOf()
tags!![name] = values
}
fun pTag(vararg pubKeys: String) {
tag("p", pubKeys.toList())
}
fun eTag(vararg eventIds: String) {
tag("e", eventIds.toList())
}
fun since(timestamp: Long) {
this.since = timestamp
}
fun until(timestamp: Long) {
this.until = timestamp
}
fun limit(limit: Int) {
this.limit = limit
}
fun search(search: String) {
this.search = search
}
fun build(): Filter = Filter(ids, authors, kinds, tags, since, until, limit, search)
}
/**
* Creates a filter using the DSL builder.
*
* Example:
* ```kotlin
* val filter = buildFilter {
* kinds(1)
* authors("pubkey1", "pubkey2")
* limit(50)
* }
* ```
*/
fun buildFilter(block: FilterBuilder.() -> Unit): Filter = FilterBuilder().apply(block).build()
@@ -0,0 +1,508 @@
/**
* 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.filters
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class FilterBuildersTest {
private val testPubKey = "0000000000000000000000000000000000000000000000000000000000000001"
private val testPubKey2 = "0000000000000000000000000000000000000000000000000000000000000002"
private val testEventId = "1111111111111111111111111111111111111111111111111111111111111111"
@Test
fun testTextNotesGlobal() {
val filter = FilterBuilders.textNotesGlobal(limit = 50)
assertEquals(listOf(1), filter.kinds)
assertEquals(50, filter.limit)
assertNull(filter.authors)
assertNull(filter.tags)
assertNull(filter.since)
assertNull(filter.until)
}
@Test
fun testTextNotesGlobalWithTimeRange() {
val since = 1609459200L // 2021-01-01
val until = 1640995200L // 2022-01-01
val filter = FilterBuilders.textNotesGlobal(limit = 100, since = since, until = until)
assertEquals(listOf(1), filter.kinds)
assertEquals(100, filter.limit)
assertEquals(since, filter.since)
assertEquals(until, filter.until)
}
@Test
fun testTextNotesFromAuthors() {
val authors = listOf(testPubKey, testPubKey2)
val filter = FilterBuilders.textNotesFromAuthors(authors, limit = 25)
assertEquals(listOf(1), filter.kinds)
assertEquals(authors, filter.authors)
assertEquals(25, filter.limit)
assertNull(filter.tags)
}
@Test
fun testTextNotesFromAuthorsWithTimeRange() {
val authors = listOf(testPubKey)
val since = 1609459200L
val filter = FilterBuilders.textNotesFromAuthors(authors, limit = 10, since = since)
assertEquals(listOf(1), filter.kinds)
assertEquals(authors, filter.authors)
assertEquals(10, filter.limit)
assertEquals(since, filter.since)
}
@Test
fun testUserMetadata() {
val filter = FilterBuilders.userMetadata(testPubKey)
assertEquals(listOf(0), filter.kinds)
assertEquals(listOf(testPubKey), filter.authors)
assertEquals(1, filter.limit)
}
@Test
fun testContactList() {
val filter = FilterBuilders.contactList(testPubKey)
assertEquals(listOf(3), filter.kinds)
assertEquals(listOf(testPubKey), filter.authors)
assertEquals(1, filter.limit)
}
@Test
fun testNotificationsForUser() {
val filter = FilterBuilders.notificationsForUser(testPubKey, limit = 100)
assertEquals(listOf(1, 7, 6, 16, 9735), filter.kinds)
assertNotNull(filter.tags)
assertEquals(listOf(testPubKey), filter.tags!!["p"])
assertEquals(100, filter.limit)
}
@Test
fun testNotificationsForUserWithSince() {
val since = 1609459200L
val filter = FilterBuilders.notificationsForUser(testPubKey, limit = 50, since = since)
assertEquals(listOf(1, 7, 6, 16, 9735), filter.kinds)
assertNotNull(filter.tags)
assertEquals(listOf(testPubKey), filter.tags!!["p"])
assertEquals(50, filter.limit)
assertEquals(since, filter.since)
}
@Test
fun testByKinds() {
val kinds = listOf(1, 7, 6)
val filter = FilterBuilders.byKinds(kinds, limit = 20)
assertEquals(kinds, filter.kinds)
assertEquals(20, filter.limit)
assertNull(filter.authors)
assertNull(filter.tags)
}
@Test
fun testByKindsWithTimeRange() {
val kinds = listOf(30023) // Long-form content
val since = 1609459200L
val until = 1640995200L
val filter = FilterBuilders.byKinds(kinds, limit = 5, since = since, until = until)
assertEquals(kinds, filter.kinds)
assertEquals(5, filter.limit)
assertEquals(since, filter.since)
assertEquals(until, filter.until)
}
@Test
fun testByAuthors() {
val authors = listOf(testPubKey, testPubKey2)
val filter = FilterBuilders.byAuthors(authors, limit = 30)
assertEquals(authors, filter.authors)
assertEquals(30, filter.limit)
assertNull(filter.kinds)
}
@Test
fun testByAuthorsWithKinds() {
val authors = listOf(testPubKey)
val kinds = listOf(1, 30023)
val filter = FilterBuilders.byAuthors(authors, kinds = kinds, limit = 15)
assertEquals(authors, filter.authors)
assertEquals(kinds, filter.kinds)
assertEquals(15, filter.limit)
}
@Test
fun testByIds() {
val ids = listOf(testEventId)
val filter = FilterBuilders.byIds(ids)
assertEquals(ids, filter.ids)
assertNull(filter.kinds)
assertNull(filter.authors)
assertNull(filter.limit)
}
@Test
fun testByPTags() {
val pubKeys = listOf(testPubKey, testPubKey2)
val filter = FilterBuilders.byPTags(pubKeys, limit = 40)
assertNotNull(filter.tags)
assertEquals(pubKeys, filter.tags!!["p"])
assertEquals(40, filter.limit)
assertNull(filter.kinds)
}
@Test
fun testByPTagsWithKinds() {
val pubKeys = listOf(testPubKey)
val kinds = listOf(7) // Reactions
val filter = FilterBuilders.byPTags(pubKeys, kinds = kinds, limit = 25)
assertNotNull(filter.tags)
assertEquals(pubKeys, filter.tags!!["p"])
assertEquals(kinds, filter.kinds)
assertEquals(25, filter.limit)
}
@Test
fun testByETags() {
val eventIds = listOf(testEventId)
val filter = FilterBuilders.byETags(eventIds, limit = 10)
assertNotNull(filter.tags)
assertEquals(eventIds, filter.tags!!["e"])
assertEquals(10, filter.limit)
assertNull(filter.kinds)
}
@Test
fun testByETagsWithKinds() {
val eventIds = listOf(testEventId)
val kinds = listOf(1, 7) // Text notes and reactions
val filter = FilterBuilders.byETags(eventIds, kinds = kinds, limit = 20)
assertNotNull(filter.tags)
assertEquals(eventIds, filter.tags!!["e"])
assertEquals(kinds, filter.kinds)
assertEquals(20, filter.limit)
}
@Test
fun testByTags() {
val tags = mapOf("p" to listOf(testPubKey), "t" to listOf("bitcoin", "nostr"))
val filter = FilterBuilders.byTags(tags, limit = 15)
assertEquals(tags, filter.tags)
assertEquals(15, filter.limit)
assertNull(filter.kinds)
}
@Test
fun testByTagsWithKinds() {
val tags = mapOf("t" to listOf("bitcoin"))
val kinds = listOf(1)
val filter = FilterBuilders.byTags(tags, kinds = kinds, limit = 50)
assertEquals(tags, filter.tags)
assertEquals(kinds, filter.kinds)
assertEquals(50, filter.limit)
}
// DSL Builder Tests
@Test
fun testBuildFilterWithKinds() {
val filter =
buildFilter {
kinds(1, 7)
limit(50)
}
assertEquals(listOf(1, 7), filter.kinds)
assertEquals(50, filter.limit)
}
@Test
fun testBuildFilterWithAuthors() {
val filter =
buildFilter {
authors(testPubKey, testPubKey2)
limit(25)
}
assertEquals(listOf(testPubKey, testPubKey2), filter.authors)
assertEquals(25, filter.limit)
}
@Test
fun testBuildFilterWithPTag() {
val filter =
buildFilter {
kinds(7)
pTag(testPubKey)
limit(100)
}
assertEquals(listOf(7), filter.kinds)
assertNotNull(filter.tags)
assertEquals(listOf(testPubKey), filter.tags!!["p"])
assertEquals(100, filter.limit)
}
@Test
fun testBuildFilterWithETag() {
val filter =
buildFilter {
kinds(1)
eTag(testEventId)
limit(10)
}
assertEquals(listOf(1), filter.kinds)
assertNotNull(filter.tags)
assertEquals(listOf(testEventId), filter.tags!!["e"])
assertEquals(10, filter.limit)
}
@Test
fun testBuildFilterWithCustomTag() {
val filter =
buildFilter {
kinds(1)
tag("t", listOf("bitcoin", "nostr"))
limit(20)
}
assertEquals(listOf(1), filter.kinds)
assertNotNull(filter.tags)
assertEquals(listOf("bitcoin", "nostr"), filter.tags!!["t"])
assertEquals(20, filter.limit)
}
@Test
fun testBuildFilterWithTimeRange() {
val since = 1609459200L
val until = 1640995200L
val filter =
buildFilter {
kinds(1)
since(since)
until(until)
limit(30)
}
assertEquals(listOf(1), filter.kinds)
assertEquals(since, filter.since)
assertEquals(until, filter.until)
assertEquals(30, filter.limit)
}
@Test
fun testBuildFilterComplex() {
val since = 1609459200L
val filter =
buildFilter {
kinds(1, 7, 6)
authors(testPubKey)
pTag(testPubKey2)
since(since)
limit(50)
}
assertEquals(listOf(1, 7, 6), filter.kinds)
assertEquals(listOf(testPubKey), filter.authors)
assertNotNull(filter.tags)
assertEquals(listOf(testPubKey2), filter.tags!!["p"])
assertEquals(since, filter.since)
assertEquals(50, filter.limit)
}
@Test
fun testBuildFilterWithSearch() {
val filter =
buildFilter {
kinds(1)
search("bitcoin")
limit(10)
}
assertEquals(listOf(1), filter.kinds)
assertEquals("bitcoin", filter.search)
assertEquals(10, filter.limit)
}
@Test
fun testBuildFilterWithIds() {
val filter =
buildFilter {
ids(testEventId)
}
assertEquals(listOf(testEventId), filter.ids)
assertNull(filter.kinds)
}
@Test
fun testBuildFilterWithIdsList() {
val ids = listOf(testEventId, "2222222222222222222222222222222222222222222222222222222222222222")
val filter =
buildFilter {
ids(ids)
}
assertEquals(ids, filter.ids)
}
@Test
fun testBuildFilterWithAuthorsList() {
val authors = listOf(testPubKey, testPubKey2)
val filter =
buildFilter {
authors(authors)
limit(15)
}
assertEquals(authors, filter.authors)
assertEquals(15, filter.limit)
}
@Test
fun testBuildFilterWithKindsList() {
val kinds = listOf(1, 7, 6, 16, 9735)
val filter =
buildFilter {
kinds(kinds)
limit(100)
}
assertEquals(kinds, filter.kinds)
assertEquals(100, filter.limit)
}
// Integration Tests - Real-world scenarios
@Test
fun testGlobalFeedScenario() {
val filter = FilterBuilders.textNotesGlobal(limit = 50)
assertTrue(filter.isFilledFilter())
assertEquals(listOf(1), filter.kinds)
assertEquals(50, filter.limit)
}
@Test
fun testFollowingFeedScenario() {
val followedUsers = listOf(testPubKey, testPubKey2)
val filter = FilterBuilders.textNotesFromAuthors(followedUsers, limit = 50)
assertTrue(filter.isFilledFilter())
assertEquals(listOf(1), filter.kinds)
assertEquals(followedUsers, filter.authors)
assertEquals(50, filter.limit)
}
@Test
fun testUserProfileScenario() {
val metadataFilter = FilterBuilders.userMetadata(testPubKey)
val postsFilter = FilterBuilders.textNotesFromAuthors(listOf(testPubKey), limit = 50)
val contactListFilter = FilterBuilders.contactList(testPubKey)
assertTrue(metadataFilter.isFilledFilter())
assertTrue(postsFilter.isFilledFilter())
assertTrue(contactListFilter.isFilledFilter())
assertEquals(listOf(0), metadataFilter.kinds)
assertEquals(listOf(1), postsFilter.kinds)
assertEquals(listOf(3), contactListFilter.kinds)
}
@Test
fun testNotificationsScenario() {
val filter = FilterBuilders.notificationsForUser(testPubKey, limit = 100)
assertTrue(filter.isFilledFilter())
assertEquals(listOf(1, 7, 6, 16, 9735), filter.kinds)
assertNotNull(filter.tags)
assertEquals(listOf(testPubKey), filter.tags!!["p"])
assertEquals(100, filter.limit)
}
@Test
fun testThreadViewScenario() {
// Getting replies to a specific event
val filter =
buildFilter {
kinds(1)
eTag(testEventId)
limit(100)
}
assertTrue(filter.isFilledFilter())
assertEquals(listOf(1), filter.kinds)
assertNotNull(filter.tags)
assertEquals(listOf(testEventId), filter.tags!!["e"])
}
@Test
fun testReactionsToEventScenario() {
val filter =
buildFilter {
kinds(7) // Reactions
eTag(testEventId)
limit(50)
}
assertTrue(filter.isFilledFilter())
assertEquals(listOf(7), filter.kinds)
assertNotNull(filter.tags)
assertEquals(listOf(testEventId), filter.tags!!["e"])
}
@Test
fun testHashtagFeedScenario() {
val filter =
buildFilter {
kinds(1)
tag("t", listOf("bitcoin"))
limit(50)
}
assertTrue(filter.isFilledFilter())
assertEquals(listOf(1), filter.kinds)
assertNotNull(filter.tags)
assertEquals(listOf("bitcoin"), filter.tags!!["t"])
}
}
@@ -0,0 +1,89 @@
/**
* 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.subscriptions
import com.vitorpamplona.amethyst.commons.filters.FilterBuilders
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* Feed mode for feed subscriptions.
*/
enum class FeedMode {
GLOBAL,
FOLLOWING,
}
/**
* Creates a subscription config for global feed (all text notes).
*/
fun createGlobalFeedSubscription(
relays: Set<NormalizedRelayUrl>,
limit: Int = 50,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig =
SubscriptionConfig(
subId = generateSubId("global-feed"),
filters = listOf(FilterBuilders.textNotesGlobal(limit = limit)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
/**
* Creates a subscription config for following feed (text notes from followed users).
*/
fun createFollowingFeedSubscription(
relays: Set<NormalizedRelayUrl>,
followedUsers: List<String>,
limit: Int = 50,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig? {
if (followedUsers.isEmpty()) return null
return SubscriptionConfig(
subId = generateSubId("following-feed"),
filters = listOf(FilterBuilders.textNotesFromAuthors(followedUsers, limit = limit)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
}
/**
* Creates a subscription config for contact list (kind 3).
*/
fun createContactListSubscription(
relays: Set<NormalizedRelayUrl>,
pubKeyHex: String,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig =
SubscriptionConfig(
subId = generateSubId("contact-list-$pubKeyHex"),
filters = listOf(FilterBuilders.contactList(pubKeyHex)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
@@ -0,0 +1,79 @@
/**
* 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.subscriptions
import com.vitorpamplona.amethyst.commons.filters.FilterBuilders
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* Creates a subscription config for user metadata (kind 0).
*/
fun createMetadataSubscription(
relays: Set<NormalizedRelayUrl>,
pubKeyHex: String,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig =
SubscriptionConfig(
subId = generateSubId("profile-metadata-$pubKeyHex"),
filters = listOf(FilterBuilders.userMetadata(pubKeyHex)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
/**
* Creates a subscription config for user posts (kind 1).
*/
fun createUserPostsSubscription(
relays: Set<NormalizedRelayUrl>,
pubKeyHex: String,
limit: Int = 50,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig =
SubscriptionConfig(
subId = generateSubId("profile-posts-$pubKeyHex"),
filters = listOf(FilterBuilders.textNotesFromAuthors(listOf(pubKeyHex), limit = limit)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
/**
* Creates a subscription config for notifications (mentions, replies, reactions, reposts, zaps).
*/
fun createNotificationsSubscription(
relays: Set<NormalizedRelayUrl>,
pubKeyHex: String,
limit: Int = 100,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig =
SubscriptionConfig(
subId = generateSubId("notifications-$pubKeyHex"),
filters = listOf(FilterBuilders.notificationsForUser(pubKeyHex, limit = limit)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
@@ -0,0 +1,114 @@
/**
* 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.subscriptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.network.RelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* Represents an active relay subscription that can be unsubscribed.
*/
data class SubscriptionHandle(
val subId: String,
val unsubscribe: () -> Unit,
)
/**
* Configuration for a relay subscription.
*/
data class SubscriptionConfig(
val subId: String,
val filters: List<Filter>,
val relays: Set<NormalizedRelayUrl>,
val onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
val onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
)
/**
* Composable that remembers a subscription and automatically unsubscribes on dispose.
*
* Usage:
* ```kotlin
* rememberSubscription(relayStatuses, pubKeyHex) {
* SubscriptionConfig(
* subId = "my-sub-${System.currentTimeMillis()}",
* filters = listOf(Filter(kinds = listOf(1), limit = 50)),
* relays = relayStatuses.keys,
* onEvent = { event, _, _, _ -> events.add(event) }
* )
* }
* ```
*/
@Composable
fun rememberSubscription(
vararg keys: Any?,
relayManager: RelayConnectionManager,
config: () -> SubscriptionConfig?,
): SubscriptionHandle? {
val subscription = remember(*keys) { config() }
DisposableEffect(*keys, subscription?.subId) {
subscription?.let { cfg ->
if (cfg.relays.isNotEmpty()) {
relayManager.subscribe(
subId = cfg.subId,
filters = cfg.filters,
relays = cfg.relays,
listener =
object : IRequestListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
cfg.onEvent(event, isLive, relay, forFilters)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
cfg.onEose(relay, forFilters)
}
},
)
}
}
onDispose {
subscription?.let { relayManager.unsubscribe(it.subId) }
}
}
return subscription?.let { SubscriptionHandle(it.subId, { relayManager.unsubscribe(it.subId) }) }
}
/**
* Generates a unique subscription ID with timestamp.
*/
fun generateSubId(prefix: String): String = "$prefix-${System.currentTimeMillis()}"