reuse some filtering logic
This commit is contained in:
+355
@@ -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()
|
||||||
+508
@@ -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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
+89
@@ -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,
|
||||||
|
)
|
||||||
+79
@@ -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,
|
||||||
|
)
|
||||||
+114
@@ -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()}"
|
||||||
@@ -11,6 +11,10 @@ sourceSets {
|
|||||||
kotlin.srcDir("src/jvmMain/kotlin")
|
kotlin.srcDir("src/jvmMain/kotlin")
|
||||||
resources.srcDir("src/jvmMain/resources")
|
resources.srcDir("src/jvmMain/resources")
|
||||||
}
|
}
|
||||||
|
test {
|
||||||
|
kotlin.srcDir("src/jvmTest/kotlin")
|
||||||
|
resources.srcDir("src/jvmTest/resources")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
@@ -40,6 +44,11 @@ dependencies {
|
|||||||
|
|
||||||
// Collections
|
// Collections
|
||||||
implementation(libs.kotlinx.collections.immutable)
|
implementation(libs.kotlinx.collections.immutable)
|
||||||
|
|
||||||
|
// Testing
|
||||||
|
testImplementation(libs.kotlin.test)
|
||||||
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
|
testImplementation(libs.okhttp)
|
||||||
}
|
}
|
||||||
|
|
||||||
compose.desktop {
|
compose.desktop {
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -54,16 +53,17 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vitorpamplona.amethyst.commons.account.AccountState
|
import com.vitorpamplona.amethyst.commons.account.AccountState
|
||||||
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
|
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
|
||||||
|
import com.vitorpamplona.amethyst.commons.subscriptions.FeedMode
|
||||||
|
import com.vitorpamplona.amethyst.commons.subscriptions.createContactListSubscription
|
||||||
|
import com.vitorpamplona.amethyst.commons.subscriptions.createFollowingFeedSubscription
|
||||||
|
import com.vitorpamplona.amethyst.commons.subscriptions.createGlobalFeedSubscription
|
||||||
|
import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription
|
||||||
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
|
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
|
||||||
import com.vitorpamplona.amethyst.commons.ui.note.NoteCard
|
import com.vitorpamplona.amethyst.commons.ui.note.NoteCard
|
||||||
import com.vitorpamplona.amethyst.commons.util.toNoteDisplayData
|
import com.vitorpamplona.amethyst.commons.util.toNoteDisplayData
|
||||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
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
|
|
||||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Note card with action buttons.
|
* Note card with action buttons.
|
||||||
@@ -95,11 +95,6 @@ fun FeedNoteCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class FeedMode {
|
|
||||||
GLOBAL,
|
|
||||||
FOLLOWING,
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun FeedScreen(
|
fun FeedScreen(
|
||||||
relayManager: DesktopRelayConnectionManager,
|
relayManager: DesktopRelayConnectionManager,
|
||||||
@@ -125,113 +120,47 @@ fun FeedScreen(
|
|||||||
var followedUsers by remember { mutableStateOf<Set<String>>(emptySet()) }
|
var followedUsers by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||||
|
|
||||||
// Load followed users for Following feed mode
|
// Load followed users for Following feed mode
|
||||||
DisposableEffect(relayStatuses, account, feedMode) {
|
rememberSubscription(relayStatuses, account, feedMode, relayManager = relayManager) {
|
||||||
val configuredRelays = relayStatuses.keys
|
val configuredRelays = relayStatuses.keys
|
||||||
if (configuredRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) {
|
if (configuredRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) {
|
||||||
val contactListSubId = "feed-contacts-${account.pubKeyHex}-${System.currentTimeMillis()}"
|
createContactListSubscription(
|
||||||
relayManager.subscribe(
|
|
||||||
subId = contactListSubId,
|
|
||||||
filters =
|
|
||||||
listOf(
|
|
||||||
Filter(
|
|
||||||
kinds = listOf(ContactListEvent.KIND),
|
|
||||||
authors = listOf(account.pubKeyHex),
|
|
||||||
limit = 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
relays = configuredRelays,
|
relays = configuredRelays,
|
||||||
listener =
|
pubKeyHex = account.pubKeyHex,
|
||||||
object : IRequestListener {
|
onEvent = { event, _, _, _ ->
|
||||||
override fun onEvent(
|
|
||||||
event: Event,
|
|
||||||
isLive: Boolean,
|
|
||||||
relay: NormalizedRelayUrl,
|
|
||||||
forFilters: List<Filter>?,
|
|
||||||
) {
|
|
||||||
if (event is ContactListEvent) {
|
if (event is ContactListEvent) {
|
||||||
followedUsers = event.verifiedFollowKeySet()
|
followedUsers = event.verifiedFollowKeySet()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
override fun onEose(
|
|
||||||
relay: NormalizedRelayUrl,
|
|
||||||
forFilters: List<Filter>?,
|
|
||||||
) {}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
onDispose {
|
|
||||||
relayManager.unsubscribe(contactListSubId)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
onDispose {}
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DisposableEffect(relayStatuses, feedMode, followedUsers) {
|
// Clear events when feed mode changes
|
||||||
|
remember(feedMode) { eventState.clear() }
|
||||||
|
|
||||||
|
// Subscribe to feed based on mode
|
||||||
|
rememberSubscription(relayStatuses, feedMode, followedUsers, relayManager = relayManager) {
|
||||||
val configuredRelays = relayStatuses.keys
|
val configuredRelays = relayStatuses.keys
|
||||||
if (configuredRelays.isNotEmpty()) {
|
if (configuredRelays.isEmpty()) return@rememberSubscription null
|
||||||
// Clear previous events when switching modes
|
|
||||||
eventState.clear()
|
|
||||||
|
|
||||||
val subId = "${feedMode.name.lowercase()}-feed-${System.currentTimeMillis()}"
|
|
||||||
val filters =
|
|
||||||
when (feedMode) {
|
when (feedMode) {
|
||||||
FeedMode.GLOBAL ->
|
FeedMode.GLOBAL ->
|
||||||
listOf(
|
createGlobalFeedSubscription(
|
||||||
Filter(
|
relays = configuredRelays,
|
||||||
kinds = listOf(TextNoteEvent.KIND),
|
onEvent = { event, _, _, _ -> eventState.addItem(event) },
|
||||||
limit = 50,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
FeedMode.FOLLOWING ->
|
FeedMode.FOLLOWING ->
|
||||||
if (followedUsers.isNotEmpty()) {
|
if (followedUsers.isNotEmpty()) {
|
||||||
listOf(
|
createFollowingFeedSubscription(
|
||||||
Filter(
|
|
||||||
kinds = listOf(TextNoteEvent.KIND),
|
|
||||||
authors = followedUsers.toList(),
|
|
||||||
limit = 50,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// No followed users yet, return empty filter
|
|
||||||
emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.isNotEmpty()) {
|
|
||||||
relayManager.subscribe(
|
|
||||||
subId = subId,
|
|
||||||
filters = filters,
|
|
||||||
relays = configuredRelays,
|
relays = configuredRelays,
|
||||||
listener =
|
followedUsers = followedUsers.toList(),
|
||||||
object : IRequestListener {
|
onEvent = { event, _, _, _ -> eventState.addItem(event) },
|
||||||
override fun onEvent(
|
|
||||||
event: Event,
|
|
||||||
isLive: Boolean,
|
|
||||||
relay: NormalizedRelayUrl,
|
|
||||||
forFilters: List<Filter>?,
|
|
||||||
) {
|
|
||||||
eventState.addItem(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onEose(
|
|
||||||
relay: NormalizedRelayUrl,
|
|
||||||
forFilters: List<Filter>?,
|
|
||||||
) {
|
|
||||||
// End of stored events
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
onDispose {
|
|
||||||
relayManager.unsubscribe(subId)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
onDispose {}
|
null
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
onDispose {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-50
@@ -39,7 +39,6 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -52,15 +51,14 @@ import com.vitorpamplona.amethyst.commons.icons.Reply
|
|||||||
import com.vitorpamplona.amethyst.commons.icons.Repost
|
import com.vitorpamplona.amethyst.commons.icons.Repost
|
||||||
import com.vitorpamplona.amethyst.commons.icons.Zap
|
import com.vitorpamplona.amethyst.commons.icons.Zap
|
||||||
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
|
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
|
||||||
|
import com.vitorpamplona.amethyst.commons.subscriptions.createNotificationsSubscription
|
||||||
|
import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription
|
||||||
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
|
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
|
||||||
import com.vitorpamplona.amethyst.commons.ui.feed.FeedHeader
|
import com.vitorpamplona.amethyst.commons.ui.feed.FeedHeader
|
||||||
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
|
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
|
||||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
|
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
|
||||||
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
|
|
||||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||||
@@ -122,42 +120,17 @@ fun NotificationsScreen(
|
|||||||
}
|
}
|
||||||
val notifications by notificationState.items.collectAsState()
|
val notifications by notificationState.items.collectAsState()
|
||||||
|
|
||||||
DisposableEffect(relayStatuses, account.pubKeyHex) {
|
// Subscribe to notifications
|
||||||
|
rememberSubscription(relayStatuses, account.pubKeyHex, relayManager = relayManager) {
|
||||||
val configuredRelays = relayStatuses.keys
|
val configuredRelays = relayStatuses.keys
|
||||||
if (configuredRelays.isNotEmpty()) {
|
if (configuredRelays.isNotEmpty()) {
|
||||||
val subId = "notifications-${account.pubKeyHex}-${System.currentTimeMillis()}"
|
createNotificationsSubscription(
|
||||||
val filters =
|
|
||||||
listOf(
|
|
||||||
// Mentions, replies, reactions, reposts, zaps
|
|
||||||
Filter(
|
|
||||||
kinds =
|
|
||||||
listOf(
|
|
||||||
TextNoteEvent.KIND, // 1 - mentions/replies
|
|
||||||
ReactionEvent.KIND, // 7 - reactions
|
|
||||||
RepostEvent.KIND, // 6 - reposts
|
|
||||||
GenericRepostEvent.KIND, // 16 - generic reposts
|
|
||||||
LnZapEvent.KIND, // 9735 - zaps
|
|
||||||
),
|
|
||||||
tags = mapOf("p" to listOf(account.pubKeyHex)), // Events mentioning user
|
|
||||||
limit = 100,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
relayManager.subscribe(
|
|
||||||
subId = subId,
|
|
||||||
filters = filters,
|
|
||||||
relays = configuredRelays,
|
relays = configuredRelays,
|
||||||
listener =
|
pubKeyHex = account.pubKeyHex,
|
||||||
object : IRequestListener {
|
onEvent = { event, _, _, _ ->
|
||||||
override fun onEvent(
|
|
||||||
event: Event,
|
|
||||||
isLive: Boolean,
|
|
||||||
relay: NormalizedRelayUrl,
|
|
||||||
forFilters: List<Filter>?,
|
|
||||||
) {
|
|
||||||
// Skip events from the user themselves (except zaps)
|
// Skip events from the user themselves (except zaps)
|
||||||
if (event.pubKey == account.pubKeyHex && event !is LnZapEvent) {
|
if (event.pubKey == account.pubKeyHex && event !is LnZapEvent) {
|
||||||
return
|
return@createNotificationsSubscription
|
||||||
}
|
}
|
||||||
|
|
||||||
val notification =
|
val notification =
|
||||||
@@ -174,7 +147,6 @@ fun NotificationsScreen(
|
|||||||
timestamp = event.createdAt,
|
timestamp = event.createdAt,
|
||||||
)
|
)
|
||||||
is LnZapEvent -> {
|
is LnZapEvent -> {
|
||||||
// Extract amount from zap (simplified - full parsing in production)
|
|
||||||
val amount = event.amount?.toLong()
|
val amount = event.amount?.toLong()
|
||||||
NotificationItem.Zap(
|
NotificationItem.Zap(
|
||||||
event = event,
|
event = event,
|
||||||
@@ -183,7 +155,6 @@ fun NotificationsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
is TextNoteEvent -> {
|
is TextNoteEvent -> {
|
||||||
// Check if it's a reply (has e-tag) or mention
|
|
||||||
val eTags = event.tags.filter { it.size > 1 && it[0] == "e" }
|
val eTags = event.tags.filter { it.size > 1 && it[0] == "e" }
|
||||||
val isReply = eTags.isNotEmpty()
|
val isReply = eTags.isNotEmpty()
|
||||||
if (isReply) {
|
if (isReply) {
|
||||||
@@ -196,22 +167,10 @@ fun NotificationsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
notificationState.addItem(notification)
|
notificationState.addItem(notification)
|
||||||
}
|
|
||||||
|
|
||||||
override fun onEose(
|
|
||||||
relay: NormalizedRelayUrl,
|
|
||||||
forFilters: List<Filter>?,
|
|
||||||
) {
|
|
||||||
// End of stored events
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
onDispose {
|
|
||||||
relayManager.unsubscribe(subId)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
onDispose { }
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+35
-113
@@ -48,7 +48,6 @@ import androidx.compose.material3.OutlinedButton
|
|||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -64,15 +63,15 @@ import com.vitorpamplona.amethyst.commons.account.AccountState
|
|||||||
import com.vitorpamplona.amethyst.commons.actions.FollowAction
|
import com.vitorpamplona.amethyst.commons.actions.FollowAction
|
||||||
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
|
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
|
||||||
import com.vitorpamplona.amethyst.commons.state.FollowState
|
import com.vitorpamplona.amethyst.commons.state.FollowState
|
||||||
|
import com.vitorpamplona.amethyst.commons.subscriptions.createContactListSubscription
|
||||||
|
import com.vitorpamplona.amethyst.commons.subscriptions.createMetadataSubscription
|
||||||
|
import com.vitorpamplona.amethyst.commons.subscriptions.createUserPostsSubscription
|
||||||
|
import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription
|
||||||
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
|
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
|
||||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
|
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
|
||||||
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
|
|
||||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -124,78 +123,38 @@ fun UserProfileScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load current user's contact list (for follow state)
|
// Load current user's contact list (for follow state)
|
||||||
DisposableEffect(relayStatuses, account) {
|
rememberSubscription(relayStatuses, account, relayManager = relayManager) {
|
||||||
val configuredRelays = relayStatuses.keys
|
val configuredRelays = relayStatuses.keys
|
||||||
if (configuredRelays.isNotEmpty() && account != null) {
|
if (configuredRelays.isNotEmpty() && account != null) {
|
||||||
val contactListSubId = "my-contacts-${account.pubKeyHex}-${System.currentTimeMillis()}"
|
createContactListSubscription(
|
||||||
relayManager.subscribe(
|
|
||||||
subId = contactListSubId,
|
|
||||||
filters =
|
|
||||||
listOf(
|
|
||||||
Filter(
|
|
||||||
kinds = listOf(ContactListEvent.KIND), // Kind 3
|
|
||||||
authors = listOf(account.pubKeyHex),
|
|
||||||
limit = 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
relays = configuredRelays,
|
relays = configuredRelays,
|
||||||
listener =
|
pubKeyHex = account.pubKeyHex,
|
||||||
object : IRequestListener {
|
onEvent = { event, _, _, _ ->
|
||||||
override fun onEvent(
|
|
||||||
event: Event,
|
|
||||||
isLive: Boolean,
|
|
||||||
relay: NormalizedRelayUrl,
|
|
||||||
forFilters: List<Filter>?,
|
|
||||||
) {
|
|
||||||
if (event is ContactListEvent) {
|
if (event is ContactListEvent) {
|
||||||
followState.updateContactList(event, pubKeyHex)
|
followState.updateContactList(event, pubKeyHex)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
override fun onEose(
|
|
||||||
relay: NormalizedRelayUrl,
|
|
||||||
forFilters: List<Filter>?,
|
|
||||||
) {}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
onDispose {
|
|
||||||
relayManager.unsubscribe(contactListSubId)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
onDispose {}
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to user metadata and posts
|
// Clear posts when profile changes
|
||||||
DisposableEffect(relayStatuses, pubKeyHex, retryTrigger) {
|
remember(pubKeyHex, retryTrigger) {
|
||||||
val configuredRelays = relayStatuses.keys
|
eventState.clear()
|
||||||
if (configuredRelays.isNotEmpty()) {
|
|
||||||
postsLoading = true
|
postsLoading = true
|
||||||
postsError = null
|
postsError = null
|
||||||
eventState.clear()
|
}
|
||||||
// Metadata subscription (kind 0)
|
|
||||||
val metadataSubId = "profile-metadata-$pubKeyHex-${System.currentTimeMillis()}"
|
// Subscribe to user metadata
|
||||||
relayManager.subscribe(
|
rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) {
|
||||||
subId = metadataSubId,
|
val configuredRelays = relayStatuses.keys
|
||||||
filters =
|
if (configuredRelays.isNotEmpty()) {
|
||||||
listOf(
|
createMetadataSubscription(
|
||||||
Filter(
|
|
||||||
kinds = listOf(0), // Metadata
|
|
||||||
authors = listOf(pubKeyHex),
|
|
||||||
limit = 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
relays = configuredRelays,
|
relays = configuredRelays,
|
||||||
listener =
|
pubKeyHex = pubKeyHex,
|
||||||
object : IRequestListener {
|
onEvent = { event, _, _, _ ->
|
||||||
override fun onEvent(
|
|
||||||
event: Event,
|
|
||||||
isLive: Boolean,
|
|
||||||
relay: NormalizedRelayUrl,
|
|
||||||
forFilters: List<Filter>?,
|
|
||||||
) {
|
|
||||||
// Parse metadata JSON (simplified - full parsing in production)
|
|
||||||
try {
|
try {
|
||||||
val content = event.content
|
val content = event.content
|
||||||
displayName = extractJsonField(content, "display_name") ?: extractJsonField(content, "name")
|
displayName = extractJsonField(content, "display_name") ?: extractJsonField(content, "name")
|
||||||
@@ -204,68 +163,31 @@ fun UserProfileScreen(
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Ignore parse errors
|
// Ignore parse errors
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
override fun onEose(
|
|
||||||
relay: NormalizedRelayUrl,
|
|
||||||
forFilters: List<Filter>?,
|
|
||||||
) {}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Posts subscription (kind 1)
|
// Subscribe to user posts
|
||||||
val postsSubId = "profile-posts-$pubKeyHex-${System.currentTimeMillis()}"
|
rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) {
|
||||||
relayManager.subscribe(
|
val configuredRelays = relayStatuses.keys
|
||||||
subId = postsSubId,
|
if (configuredRelays.isNotEmpty()) {
|
||||||
filters =
|
createUserPostsSubscription(
|
||||||
listOf(
|
|
||||||
Filter(
|
|
||||||
kinds = listOf(TextNoteEvent.KIND),
|
|
||||||
authors = listOf(pubKeyHex),
|
|
||||||
limit = 50,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
relays = configuredRelays,
|
relays = configuredRelays,
|
||||||
listener =
|
pubKeyHex = pubKeyHex,
|
||||||
object : IRequestListener {
|
onEvent = { event, _, _, _ ->
|
||||||
override fun onEvent(
|
|
||||||
event: Event,
|
|
||||||
isLive: Boolean,
|
|
||||||
relay: NormalizedRelayUrl,
|
|
||||||
forFilters: List<Filter>?,
|
|
||||||
) {
|
|
||||||
eventState.addItem(event)
|
eventState.addItem(event)
|
||||||
}
|
},
|
||||||
|
onEose = { _, _ ->
|
||||||
override fun onEose(
|
|
||||||
relay: NormalizedRelayUrl,
|
|
||||||
forFilters: List<Filter>?,
|
|
||||||
) {
|
|
||||||
// At least one relay finished sending events
|
|
||||||
postsLoading = false
|
postsLoading = false
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// Set timeout for loading state
|
|
||||||
val timeoutJob =
|
|
||||||
kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Default).launch {
|
|
||||||
kotlinx.coroutines.delay(10000) // 10 second timeout
|
|
||||||
if (postsLoading) {
|
|
||||||
postsError = "Request timed out. Check relay connections."
|
|
||||||
postsLoading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onDispose {
|
|
||||||
timeoutJob.cancel()
|
|
||||||
relayManager.unsubscribe(metadataSubId)
|
|
||||||
relayManager.unsubscribe(postsSubId)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
postsLoading = false
|
postsLoading = false
|
||||||
postsError = "No relays configured"
|
postsError = "No relays configured"
|
||||||
onDispose {}
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+92
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* 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.desktop.network
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class DesktopHttpClientTest {
|
||||||
|
@Test
|
||||||
|
fun testGetHttpClientReturnsConfiguredClient() {
|
||||||
|
val url = NormalizedRelayUrl("wss://relay.damus.io")
|
||||||
|
val client = DesktopHttpClient.getHttpClient(url)
|
||||||
|
|
||||||
|
assertNotNull(client)
|
||||||
|
assertEquals(30_000, client.connectTimeoutMillis)
|
||||||
|
assertEquals(30_000, client.readTimeoutMillis)
|
||||||
|
assertEquals(30_000, client.writeTimeoutMillis)
|
||||||
|
assertEquals(30_000, client.pingIntervalMillis)
|
||||||
|
assertTrue(client.retryOnConnectionFailure)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testGetHttpClientReturnsSameInstance() {
|
||||||
|
val url1 = NormalizedRelayUrl("wss://relay.damus.io")
|
||||||
|
val url2 = NormalizedRelayUrl("wss://nos.lol")
|
||||||
|
|
||||||
|
val client1 = DesktopHttpClient.getHttpClient(url1)
|
||||||
|
val client2 = DesktopHttpClient.getHttpClient(url2)
|
||||||
|
|
||||||
|
// Should return the same singleton instance
|
||||||
|
assertEquals(client1, client2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testHttpClientHasExpectedTimeouts() {
|
||||||
|
val url = NormalizedRelayUrl("wss://relay.nostr.band")
|
||||||
|
val client = DesktopHttpClient.getHttpClient(url)
|
||||||
|
|
||||||
|
// Verify all timeouts are 30 seconds
|
||||||
|
assertEquals(30_000, client.connectTimeoutMillis)
|
||||||
|
assertEquals(30_000, client.readTimeoutMillis)
|
||||||
|
assertEquals(30_000, client.writeTimeoutMillis)
|
||||||
|
assertEquals(30_000, client.pingIntervalMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testHttpClientHasRetryEnabled() {
|
||||||
|
val url = NormalizedRelayUrl("wss://relay.snort.social")
|
||||||
|
val client = DesktopHttpClient.getHttpClient(url)
|
||||||
|
|
||||||
|
assertTrue(client.retryOnConnectionFailure, "Retry on connection failure should be enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testHttpClientIsLazyInitialized() {
|
||||||
|
// This test verifies the lazy initialization pattern
|
||||||
|
// The client should be created only once even with multiple calls
|
||||||
|
val url1 = NormalizedRelayUrl("wss://relay1.example.com")
|
||||||
|
val url2 = NormalizedRelayUrl("wss://relay2.example.com")
|
||||||
|
val url3 = NormalizedRelayUrl("wss://relay3.example.com")
|
||||||
|
|
||||||
|
val client1 = DesktopHttpClient.getHttpClient(url1)
|
||||||
|
val client2 = DesktopHttpClient.getHttpClient(url2)
|
||||||
|
val client3 = DesktopHttpClient.getHttpClient(url3)
|
||||||
|
|
||||||
|
// All should be the same instance due to lazy singleton
|
||||||
|
assertEquals(client1, client2)
|
||||||
|
assertEquals(client2, client3)
|
||||||
|
assertEquals(client1, client3)
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* 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.desktop.network
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class DesktopRelayConnectionManagerTest {
|
||||||
|
@Test
|
||||||
|
fun testRelayConnectionManagerCanBeInstantiated() {
|
||||||
|
val manager = DesktopRelayConnectionManager()
|
||||||
|
assertNotNull(manager)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testRelayConnectionManagerHasNoActiveConnectionsInitially() {
|
||||||
|
val manager = DesktopRelayConnectionManager()
|
||||||
|
val connectedRelays = manager.connectedRelays.value
|
||||||
|
val availableRelays = manager.availableRelays.value
|
||||||
|
assertTrue(connectedRelays.isEmpty(), "Should have no connected relays on initialization")
|
||||||
|
assertTrue(availableRelays.isEmpty(), "Should have no available relays on initialization")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testRelayConnectionManagerInheritsFromBaseClass() {
|
||||||
|
val manager = DesktopRelayConnectionManager()
|
||||||
|
assertTrue(
|
||||||
|
manager is com.vitorpamplona.amethyst.commons.network.RelayConnectionManager,
|
||||||
|
"DesktopRelayConnectionManager should extend RelayConnectionManager",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user