reuse some filtering logic

This commit is contained in:
nrobi144
2026-01-03 06:15:37 +02:00
parent 36bb89fd36
commit f800e20b05
11 changed files with 1427 additions and 320 deletions
+9
View File
@@ -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 {
@@ -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<Set<String>>(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<Filter>?,
) {
if (event is ContactListEvent) {
followedUsers = event.verifiedFollowKeySet()
}
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {}
},
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<Filter>?,
) {
eventState.addItem(event)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
// 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 {}
}
}
@@ -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<Filter>?,
) {
// 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<Filter>?,
) {
// End of stored events
}
},
notificationState.addItem(notification)
},
)
onDispose {
relayManager.unsubscribe(subId)
}
} else {
onDispose { }
null
}
}
@@ -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<Filter>?,
) {
if (event is ContactListEvent) {
followState.updateContactList(event, pubKeyHex)
}
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {}
},
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<Filter>?,
) {
// 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<Filter>?,
) {}
},
)
// 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<Filter>?,
) {
eventState.addItem(event)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
// 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
}
}
@@ -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)
}
}
@@ -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",
)
}
}