From f800e20b05f727c731e9dc4a1e4aae171ed9c200 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Sat, 3 Jan 2026 06:15:37 +0200 Subject: [PATCH] reuse some filtering logic --- .../commons/filters/FilterBuilders.kt | 355 ++++++++++++ .../commons/filters/FilterBuildersTest.kt | 508 ++++++++++++++++++ .../commons/subscriptions/FeedSubscription.kt | 89 +++ .../subscriptions/ProfileSubscription.kt | 79 +++ .../subscriptions/SubscriptionUtils.kt | 114 ++++ desktopApp/build.gradle.kts | 9 + .../amethyst/desktop/ui/FeedScreen.kt | 137 ++--- .../desktop/ui/NotificationsScreen.kt | 129 ++--- .../amethyst/desktop/ui/UserProfileScreen.kt | 184 ++----- .../desktop/network/DesktopHttpClientTest.kt | 92 ++++ .../DesktopRelayConnectionManagerTest.kt | 51 ++ 11 files changed, 1427 insertions(+), 320 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/filters/FilterBuilders.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/filters/FilterBuildersTest.kt create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/FeedSubscription.kt create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/ProfileSubscription.kt create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/SubscriptionUtils.kt create mode 100644 desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClientTest.kt create mode 100644 desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManagerTest.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/filters/FilterBuilders.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/filters/FilterBuilders.kt new file mode 100644 index 000000000..33bee5095 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/filters/FilterBuilders.kt @@ -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, + 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, + 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, + kinds: List? = 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): 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, + kinds: List? = 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, + kinds: List? = 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>, + kinds: List? = 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? = null + private var authors: List? = null + private var kinds: List? = null + private var tags: MutableMap>? = 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) { + this.ids = ids + } + + fun authors(vararg authors: String) { + this.authors = authors.toList() + } + + fun authors(authors: List) { + this.authors = authors + } + + fun kinds(vararg kinds: Int) { + this.kinds = kinds.toList() + } + + fun kinds(kinds: List) { + this.kinds = kinds + } + + fun tag( + name: String, + values: List, + ) { + 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() diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/filters/FilterBuildersTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/filters/FilterBuildersTest.kt new file mode 100644 index 000000000..655c4d99f --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/filters/FilterBuildersTest.kt @@ -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"]) + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/FeedSubscription.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/FeedSubscription.kt new file mode 100644 index 000000000..66454159c --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/FeedSubscription.kt @@ -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, + limit: Int = 50, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> 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, + followedUsers: List, + limit: Int = 50, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> 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, + pubKeyHex: String, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig = + SubscriptionConfig( + subId = generateSubId("contact-list-$pubKeyHex"), + filters = listOf(FilterBuilders.contactList(pubKeyHex)), + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/ProfileSubscription.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/ProfileSubscription.kt new file mode 100644 index 000000000..b1cbdaea4 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/ProfileSubscription.kt @@ -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, + pubKeyHex: String, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> 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, + pubKeyHex: String, + limit: Int = 50, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> 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, + pubKeyHex: String, + limit: Int = 100, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig = + SubscriptionConfig( + subId = generateSubId("notifications-$pubKeyHex"), + filters = listOf(FilterBuilders.notificationsForUser(pubKeyHex, limit = limit)), + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/SubscriptionUtils.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/SubscriptionUtils.kt new file mode 100644 index 000000000..269901810 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/SubscriptionUtils.kt @@ -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, + val relays: Set, + val onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + val onEose: (NormalizedRelayUrl, List?) -> 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?, + ) { + cfg.onEvent(event, isLive, relay, forFilters) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + 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()}" diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 5bd8c6c60..b82eb646b 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -11,6 +11,10 @@ sourceSets { kotlin.srcDir("src/jvmMain/kotlin") resources.srcDir("src/jvmMain/resources") } + test { + kotlin.srcDir("src/jvmTest/kotlin") + resources.srcDir("src/jvmTest/resources") + } } kotlin { @@ -40,6 +44,11 @@ dependencies { // Collections implementation(libs.kotlinx.collections.immutable) + + // Testing + testImplementation(libs.kotlin.test) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.okhttp) } compose.desktop { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index ef027ebbb..b162357e6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -42,7 +42,6 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -54,16 +53,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.account.AccountState 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.note.NoteCard import com.vitorpamplona.amethyst.commons.util.toNoteDisplayData import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager 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.nip10Notes.TextNoteEvent /** * Note card with action buttons. @@ -95,11 +95,6 @@ fun FeedNoteCard( } } -enum class FeedMode { - GLOBAL, - FOLLOWING, -} - @Composable fun FeedScreen( relayManager: DesktopRelayConnectionManager, @@ -125,113 +120,47 @@ fun FeedScreen( var followedUsers by remember { mutableStateOf>(emptySet()) } // Load followed users for Following feed mode - DisposableEffect(relayStatuses, account, feedMode) { + rememberSubscription(relayStatuses, account, feedMode, relayManager = relayManager) { val configuredRelays = relayStatuses.keys if (configuredRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) { - val contactListSubId = "feed-contacts-${account.pubKeyHex}-${System.currentTimeMillis()}" - relayManager.subscribe( - subId = contactListSubId, - filters = - listOf( - Filter( - kinds = listOf(ContactListEvent.KIND), - authors = listOf(account.pubKeyHex), - limit = 1, - ), - ), + createContactListSubscription( relays = configuredRelays, - listener = - object : IRequestListener { - override fun onEvent( - event: Event, - isLive: Boolean, - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - if (event is ContactListEvent) { - followedUsers = event.verifiedFollowKeySet() - } - } - - override fun onEose( - relay: NormalizedRelayUrl, - forFilters: List?, - ) {} - }, + pubKeyHex = account.pubKeyHex, + onEvent = { event, _, _, _ -> + if (event is ContactListEvent) { + followedUsers = event.verifiedFollowKeySet() + } + }, ) - - onDispose { - relayManager.unsubscribe(contactListSubId) - } } 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 - if (configuredRelays.isNotEmpty()) { - // Clear previous events when switching modes - eventState.clear() + if (configuredRelays.isEmpty()) return@rememberSubscription null - val subId = "${feedMode.name.lowercase()}-feed-${System.currentTimeMillis()}" - val filters = - when (feedMode) { - FeedMode.GLOBAL -> - listOf( - Filter( - kinds = listOf(TextNoteEvent.KIND), - limit = 50, - ), - ) - FeedMode.FOLLOWING -> - if (followedUsers.isNotEmpty()) { - listOf( - 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, + when (feedMode) { + FeedMode.GLOBAL -> + createGlobalFeedSubscription( relays = configuredRelays, - listener = - object : IRequestListener { - override fun onEvent( - event: Event, - isLive: Boolean, - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - eventState.addItem(event) - } - - override fun onEose( - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - // End of stored events - } - }, + onEvent = { event, _, _, _ -> eventState.addItem(event) }, ) - - onDispose { - relayManager.unsubscribe(subId) + FeedMode.FOLLOWING -> + if (followedUsers.isNotEmpty()) { + createFollowingFeedSubscription( + relays = configuredRelays, + followedUsers = followedUsers.toList(), + onEvent = { event, _, _, _ -> eventState.addItem(event) }, + ) + } else { + null } - } else { - onDispose {} - } - } else { - onDispose {} } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt index 336496408..772f05219 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt @@ -39,7 +39,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -52,15 +51,14 @@ import com.vitorpamplona.amethyst.commons.icons.Reply import com.vitorpamplona.amethyst.commons.icons.Repost import com.vitorpamplona.amethyst.commons.icons.Zap 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.feed.FeedHeader import com.vitorpamplona.amethyst.commons.util.toTimeAgo import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.quartz.nip01Core.core.Event 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.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent @@ -122,96 +120,57 @@ fun NotificationsScreen( } val notifications by notificationState.items.collectAsState() - DisposableEffect(relayStatuses, account.pubKeyHex) { + // Subscribe to notifications + rememberSubscription(relayStatuses, account.pubKeyHex, relayManager = relayManager) { val configuredRelays = relayStatuses.keys if (configuredRelays.isNotEmpty()) { - val subId = "notifications-${account.pubKeyHex}-${System.currentTimeMillis()}" - 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, + createNotificationsSubscription( relays = configuredRelays, - listener = - object : IRequestListener { - override fun onEvent( - event: Event, - isLive: Boolean, - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - // Skip events from the user themselves (except zaps) - if (event.pubKey == account.pubKeyHex && event !is LnZapEvent) { - return + pubKeyHex = account.pubKeyHex, + onEvent = { event, _, _, _ -> + // Skip events from the user themselves (except zaps) + if (event.pubKey == account.pubKeyHex && event !is LnZapEvent) { + return@createNotificationsSubscription + } + + val notification = + when (event) { + is ReactionEvent -> + NotificationItem.Reaction( + event = event, + timestamp = event.createdAt, + content = event.content, + ) + is RepostEvent, is GenericRepostEvent -> + NotificationItem.Repost( + event = event, + timestamp = event.createdAt, + ) + is LnZapEvent -> { + val amount = event.amount?.toLong() + NotificationItem.Zap( + event = event, + timestamp = event.createdAt, + amount = amount, + ) } - - val notification = - when (event) { - is ReactionEvent -> - NotificationItem.Reaction( - event = event, - timestamp = event.createdAt, - content = event.content, - ) - is RepostEvent, is GenericRepostEvent -> - NotificationItem.Repost( - event = event, - timestamp = event.createdAt, - ) - is LnZapEvent -> { - // Extract amount from zap (simplified - full parsing in production) - val amount = event.amount?.toLong() - NotificationItem.Zap( - event = event, - timestamp = event.createdAt, - amount = amount, - ) - } - 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 isReply = eTags.isNotEmpty() - if (isReply) { - NotificationItem.Reply(event, event.createdAt) - } else { - NotificationItem.Mention(event, event.createdAt) - } - } - else -> NotificationItem.Mention(event, event.createdAt) + is TextNoteEvent -> { + val eTags = event.tags.filter { it.size > 1 && it[0] == "e" } + val isReply = eTags.isNotEmpty() + if (isReply) { + NotificationItem.Reply(event, event.createdAt) + } else { + NotificationItem.Mention(event, event.createdAt) } - - notificationState.addItem(notification) + } + else -> NotificationItem.Mention(event, event.createdAt) } - override fun onEose( - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - // End of stored events - } - }, + notificationState.addItem(notification) + }, ) - - onDispose { - relayManager.unsubscribe(subId) - } } else { - onDispose { } + null } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 52ae95e56..41402280c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -48,7 +48,6 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -64,15 +63,15 @@ import com.vitorpamplona.amethyst.commons.account.AccountState import com.vitorpamplona.amethyst.commons.actions.FollowAction import com.vitorpamplona.amethyst.commons.state.EventCollectionState 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.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.quartz.nip01Core.core.Event 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.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip19Bech32.toNpub import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -124,148 +123,71 @@ fun UserProfileScreen( } // Load current user's contact list (for follow state) - DisposableEffect(relayStatuses, account) { + rememberSubscription(relayStatuses, account, relayManager = relayManager) { val configuredRelays = relayStatuses.keys if (configuredRelays.isNotEmpty() && account != null) { - val contactListSubId = "my-contacts-${account.pubKeyHex}-${System.currentTimeMillis()}" - relayManager.subscribe( - subId = contactListSubId, - filters = - listOf( - Filter( - kinds = listOf(ContactListEvent.KIND), // Kind 3 - authors = listOf(account.pubKeyHex), - limit = 1, - ), - ), + createContactListSubscription( relays = configuredRelays, - listener = - object : IRequestListener { - override fun onEvent( - event: Event, - isLive: Boolean, - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - if (event is ContactListEvent) { - followState.updateContactList(event, pubKeyHex) - } - } - - override fun onEose( - relay: NormalizedRelayUrl, - forFilters: List?, - ) {} - }, + pubKeyHex = account.pubKeyHex, + onEvent = { event, _, _, _ -> + if (event is ContactListEvent) { + followState.updateContactList(event, pubKeyHex) + } + }, ) - - onDispose { - relayManager.unsubscribe(contactListSubId) - } } else { - onDispose {} + null } } - // Subscribe to user metadata and posts - DisposableEffect(relayStatuses, pubKeyHex, retryTrigger) { + // Clear posts when profile changes + remember(pubKeyHex, retryTrigger) { + eventState.clear() + postsLoading = true + postsError = null + } + + // Subscribe to user metadata + rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) { val configuredRelays = relayStatuses.keys if (configuredRelays.isNotEmpty()) { - postsLoading = true - postsError = null - eventState.clear() - // Metadata subscription (kind 0) - val metadataSubId = "profile-metadata-$pubKeyHex-${System.currentTimeMillis()}" - relayManager.subscribe( - subId = metadataSubId, - filters = - listOf( - Filter( - kinds = listOf(0), // Metadata - authors = listOf(pubKeyHex), - limit = 1, - ), - ), + createMetadataSubscription( relays = configuredRelays, - listener = - object : IRequestListener { - override fun onEvent( - event: Event, - isLive: Boolean, - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - // Parse metadata JSON (simplified - full parsing in production) - try { - val content = event.content - displayName = extractJsonField(content, "display_name") ?: extractJsonField(content, "name") - about = extractJsonField(content, "about") - picture = extractJsonField(content, "picture") - } catch (e: Exception) { - // Ignore parse errors - } - } - - override fun onEose( - relay: NormalizedRelayUrl, - forFilters: List?, - ) {} - }, - ) - - // Posts subscription (kind 1) - val postsSubId = "profile-posts-$pubKeyHex-${System.currentTimeMillis()}" - relayManager.subscribe( - subId = postsSubId, - filters = - listOf( - Filter( - kinds = listOf(TextNoteEvent.KIND), - authors = listOf(pubKeyHex), - limit = 50, - ), - ), - relays = configuredRelays, - listener = - object : IRequestListener { - override fun onEvent( - event: Event, - isLive: Boolean, - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - eventState.addItem(event) - } - - override fun onEose( - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - // At least one relay finished sending events - 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 + pubKeyHex = pubKeyHex, + onEvent = { event, _, _, _ -> + try { + val content = event.content + displayName = extractJsonField(content, "display_name") ?: extractJsonField(content, "name") + about = extractJsonField(content, "about") + picture = extractJsonField(content, "picture") + } catch (e: Exception) { + // Ignore parse errors } - } + }, + ) + } else { + null + } + } - onDispose { - timeoutJob.cancel() - relayManager.unsubscribe(metadataSubId) - relayManager.unsubscribe(postsSubId) - } + // Subscribe to user posts + rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isNotEmpty()) { + createUserPostsSubscription( + relays = configuredRelays, + pubKeyHex = pubKeyHex, + onEvent = { event, _, _, _ -> + eventState.addItem(event) + }, + onEose = { _, _ -> + postsLoading = false + }, + ) } else { postsLoading = false postsError = "No relays configured" - onDispose {} + null } } diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClientTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClientTest.kt new file mode 100644 index 000000000..ef29957c0 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClientTest.kt @@ -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) + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManagerTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManagerTest.kt new file mode 100644 index 000000000..3075e610a --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManagerTest.kt @@ -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", + ) + } +}