Merge upstream/main into nrobi144/phase-2A

Resolved import conflicts caused by package reorganization:
- upstream moved classes to amethyst/service/relayClient/*
- upstream moved models to commons/model/*
- kept our desktop additions (zaps, bookmarks, search)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-01-21 06:04:13 +02:00
244 changed files with 3194 additions and 1352 deletions
@@ -78,11 +78,9 @@ import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
import com.vitorpamplona.amethyst.commons.account.AccountManager
import com.vitorpamplona.amethyst.commons.account.AccountState
import com.vitorpamplona.amethyst.commons.ui.profile.ProfileInfoCard
import com.vitorpamplona.amethyst.commons.ui.relay.RelayStatusCard
import com.vitorpamplona.amethyst.commons.ui.screens.MessagesPlaceholder
import com.vitorpamplona.amethyst.desktop.account.AccountManager
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
@@ -96,6 +94,8 @@ import com.vitorpamplona.amethyst.desktop.ui.SearchScreen
import com.vitorpamplona.amethyst.desktop.ui.ThreadScreen
import com.vitorpamplona.amethyst.desktop.ui.UserProfileScreen
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard
import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -0,0 +1,274 @@
/**
* 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.account
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
import com.vitorpamplona.amethyst.commons.keystorage.SecureStorageException
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip19Bech32.toNsec
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.io.File
sealed class AccountState {
data object LoggedOut : AccountState()
data class LoggedIn(
val signer: NostrSigner,
val pubKeyHex: String,
val npub: String,
val nsec: String?,
val isReadOnly: Boolean,
) : AccountState()
}
@Stable
class AccountManager private constructor(
private val secureStorage: SecureKeyStorage,
) {
companion object {
/**
* Creates an AccountManager instance.
*
* @param context Platform-specific context (required on Android, ignored on Desktop)
* @return AccountManager instance
*/
fun create(context: Any? = null): AccountManager {
val storage = SecureKeyStorage.create(context)
return AccountManager(storage)
}
}
private val _accountState = MutableStateFlow<AccountState>(AccountState.LoggedOut)
val accountState: StateFlow<AccountState> = _accountState.asStateFlow()
private val _nwcConnection = MutableStateFlow<Nip47WalletConnect.Nip47URINorm?>(null)
val nwcConnection: StateFlow<Nip47WalletConnect.Nip47URINorm?> = _nwcConnection.asStateFlow()
/**
* Loads the last saved account from secure storage.
* Call on app startup.
*/
suspend fun loadSavedAccount(): Result<AccountState.LoggedIn> {
return try {
// For simplicity, we'll store the last logged-in npub in a simple file
// and use SecureKeyStorage to retrieve the private key
val lastNpub = getLastNpub() ?: return Result.failure(Exception("No saved account"))
val privKeyHex =
secureStorage.getPrivateKey(lastNpub)
?: return Result.failure(Exception("Private key not found for $lastNpub"))
val keyPair = KeyPair(privKey = privKeyHex.hexToByteArray())
val signer = NostrSignerInternal(keyPair)
val state =
AccountState.LoggedIn(
signer = signer,
pubKeyHex = keyPair.pubKey.toHexKey(),
npub = keyPair.pubKey.toNpub(),
nsec = keyPair.privKey?.toNsec(),
isReadOnly = false,
)
_accountState.value = state
Result.success(state)
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Saves the current account to secure storage.
*/
suspend fun saveCurrentAccount(): Result<Unit> {
val current = currentAccount() ?: return Result.failure(Exception("No account logged in"))
if (current.isReadOnly || current.nsec == null) {
return Result.failure(Exception("Cannot save read-only account"))
}
return try {
val privKeyHex =
decodePrivateKeyAsHexOrNull(current.nsec)
?: return Result.failure(Exception("Invalid nsec format"))
secureStorage.savePrivateKey(current.npub, privKeyHex)
saveLastNpub(current.npub)
Result.success(Unit)
} catch (e: SecureStorageException) {
Result.failure(e)
}
}
fun generateNewAccount(): AccountState.LoggedIn {
val keyPair = KeyPair()
val signer = NostrSignerInternal(keyPair)
val state =
AccountState.LoggedIn(
signer = signer,
pubKeyHex = keyPair.pubKey.toHexKey(),
npub = keyPair.pubKey.toNpub(),
nsec = keyPair.privKey?.toNsec(),
isReadOnly = false,
)
_accountState.value = state
return state
}
fun loginWithKey(keyInput: String): Result<AccountState.LoggedIn> {
val trimmedInput = keyInput.trim()
// Try as private key first (nsec or hex)
val privKeyHex = decodePrivateKeyAsHexOrNull(trimmedInput)
if (privKeyHex != null) {
return try {
val keyPair = KeyPair(privKey = privKeyHex.hexToByteArray())
val signer = NostrSignerInternal(keyPair)
val state =
AccountState.LoggedIn(
signer = signer,
pubKeyHex = keyPair.pubKey.toHexKey(),
npub = keyPair.pubKey.toNpub(),
nsec = keyPair.privKey?.toNsec(),
isReadOnly = false,
)
_accountState.value = state
Result.success(state)
} catch (e: Exception) {
Result.failure(IllegalArgumentException("Invalid private key format"))
}
}
// Try as public key (npub or hex) - read-only mode
val pubKeyHex = decodePublicKeyAsHexOrNull(trimmedInput)
if (pubKeyHex != null) {
return try {
val keyPair = KeyPair(pubKey = pubKeyHex.hexToByteArray())
val signer = NostrSignerInternal(keyPair)
val state =
AccountState.LoggedIn(
signer = signer,
pubKeyHex = keyPair.pubKey.toHexKey(),
npub = keyPair.pubKey.toNpub(),
nsec = null,
isReadOnly = true,
)
_accountState.value = state
Result.success(state)
} catch (e: Exception) {
Result.failure(IllegalArgumentException("Invalid public key format"))
}
}
return Result.failure(IllegalArgumentException("Invalid key format. Use nsec1, npub1, or hex format."))
}
suspend fun logout(deleteKey: Boolean = false) {
val current = currentAccount()
if (deleteKey && current != null) {
try {
secureStorage.deletePrivateKey(current.npub)
clearLastNpub()
} catch (e: SecureStorageException) {
// Log error but still logout
}
}
_accountState.value = AccountState.LoggedOut
}
fun isLoggedIn(): Boolean = _accountState.value is AccountState.LoggedIn
fun currentAccount(): AccountState.LoggedIn? = _accountState.value as? AccountState.LoggedIn
// NWC (Nostr Wallet Connect) methods
fun hasNwcSetup(): Boolean = _nwcConnection.value != null
fun setNwcConnection(uri: String): Result<Nip47WalletConnect.Nip47URINorm> =
try {
val parsed = Nip47WalletConnect.parse(uri)
_nwcConnection.value = parsed
saveNwcUri(uri)
Result.success(parsed)
} catch (e: Exception) {
Result.failure(e)
}
fun clearNwcConnection() {
_nwcConnection.value = null
getNwcFile().delete()
}
fun loadNwcConnection() {
val uri = getNwcFile().takeIf { it.exists() }?.readText()?.trim()
if (!uri.isNullOrEmpty()) {
try {
_nwcConnection.value = Nip47WalletConnect.parse(uri)
} catch (e: Exception) {
// Invalid stored URI, clear it
getNwcFile().delete()
}
}
}
private fun saveNwcUri(uri: String) {
val file = getNwcFile()
file.parentFile?.mkdirs()
file.writeText(uri)
}
private fun getNwcFile(): java.io.File {
val homeDir = System.getProperty("user.home")
return java.io.File(homeDir, ".amethyst/nwc_connection.txt")
}
// Simple file-based storage for last npub (non-sensitive data)
private fun getLastNpub(): String? {
val file = getPrefsFile()
return if (file.exists()) file.readText().trim().takeIf { it.isNotEmpty() } else null
}
private fun saveLastNpub(npub: String) {
val file = getPrefsFile()
file.parentFile?.mkdirs()
file.writeText(npub)
}
private fun clearLastNpub() {
getPrefsFile().delete()
}
private fun getPrefsFile(): File {
val homeDir = System.getProperty("user.home")
return File(homeDir, ".amethyst/last_account.txt")
}
}
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.desktop.network
import com.vitorpamplona.amethyst.commons.network.RelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
/**
@@ -0,0 +1,226 @@
/**
* 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.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Manages Nostr relay connections, subscriptions, and status tracking.
* Can be used by both Android and Desktop apps.
*
* @param websocketBuilder Platform-specific websocket builder (e.g., OkHttp-based)
*/
open class RelayConnectionManager(
websocketBuilder: WebsocketBuilder,
) : IRelayClientListener {
private val _client = NostrClient(websocketBuilder)
/** Exposes the underlying INostrClient for subscription coordinators */
val client: com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient get() = _client
private val _relayStatuses = MutableStateFlow<Map<NormalizedRelayUrl, RelayStatus>>(emptyMap())
val relayStatuses: StateFlow<Map<NormalizedRelayUrl, RelayStatus>> = _relayStatuses.asStateFlow()
val connectedRelays: StateFlow<Set<NormalizedRelayUrl>> = _client.connectedRelaysFlow()
val availableRelays: StateFlow<Set<NormalizedRelayUrl>> = _client.availableRelaysFlow()
init {
_client.subscribe(this)
}
fun connect() {
_client.connect()
}
fun disconnect() {
_client.disconnect()
}
fun addRelay(url: String): NormalizedRelayUrl? {
val normalized = RelayUrlNormalizer.Companion.normalizeOrNull(url) ?: return null
updateRelayStatus(normalized) { it.copy(connected = false, error = null) }
return normalized
}
fun removeRelay(url: NormalizedRelayUrl) {
_relayStatuses.value = _relayStatuses.value - url
}
fun addDefaultRelays() {
DefaultRelays.RELAYS.forEach { addRelay(it) }
}
fun subscribe(
subId: String,
filters: List<Filter>,
relays: Set<NormalizedRelayUrl> = availableRelays.value,
listener: IRequestListener? = null,
) {
val filterMap = relays.associateWith { filters }
_client.openReqSubscription(subId, filterMap, listener)
}
fun unsubscribe(subId: String) {
_client.close(subId)
}
fun send(
event: Event,
relays: Set<NormalizedRelayUrl> = connectedRelays.value,
) {
_client.send(event, relays)
}
/**
* Broadcasts an event to all connected relays.
*/
fun broadcastToAll(event: Event) {
val connected = connectedRelays.value
send(event, connected)
}
/**
* Sends an event to a specific relay (for NWC).
* Adds the relay if not already in the list.
*/
fun sendToRelay(
relay: NormalizedRelayUrl,
event: Event,
) {
if (relay !in availableRelays.value) {
updateRelayStatus(relay) { it.copy(connected = false, error = null) }
}
_client.send(event, setOf(relay))
}
/**
* Subscribes on a specific relay (for NWC).
* Adds the relay if not already in the list.
*/
fun subscribeOnRelay(
relay: NormalizedRelayUrl,
subId: String,
filters: List<Filter>,
onEvent: (Event, NormalizedRelayUrl) -> Unit,
) {
if (relay !in availableRelays.value) {
updateRelayStatus(relay) { it.copy(connected = false, error = null) }
}
val filterMap = mapOf(relay to filters)
_client.openReqSubscription(
subId = subId,
filters = filterMap,
listener =
object : IRequestListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
onEvent(event, relay)
}
},
)
}
/**
* Closes a subscription on a specific relay.
*/
fun closeSubscription(
relay: NormalizedRelayUrl,
subId: String,
) {
_client.close(subId)
}
private fun updateRelayStatus(
url: NormalizedRelayUrl,
update: (RelayStatus) -> RelayStatus,
) {
_relayStatuses.value =
_relayStatuses.value.toMutableMap().apply {
val current = this[url] ?: RelayStatus(url, connected = false)
this[url] = update(current)
}
}
override fun onConnecting(relay: IRelayClient) {
updateRelayStatus(relay.url) {
it.copy(connected = false, error = null)
}
}
override fun onConnected(
relay: IRelayClient,
pingMillis: Int,
compressed: Boolean,
) {
updateRelayStatus(relay.url) {
it.copy(connected = true, pingMs = pingMillis, compressed = compressed, error = null)
}
}
override fun onDisconnected(relay: IRelayClient) {
updateRelayStatus(relay.url) {
it.copy(connected = false)
}
}
override fun onCannotConnect(
relay: IRelayClient,
errorMessage: String,
) {
updateRelayStatus(relay.url) {
it.copy(connected = false, error = errorMessage)
}
}
override fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
) {
// Events are handled by subscription listeners
}
override fun onSent(
relay: IRelayClient,
cmdStr: String,
cmd: Command,
success: Boolean,
) {
// Command send tracking
}
}
@@ -0,0 +1,49 @@
/**
* 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
/**
* Represents the connection status of a Nostr relay.
* Used by both Android and Desktop apps.
*/
data class RelayStatus(
val url: NormalizedRelayUrl,
val connected: Boolean,
val pingMs: Int? = null,
val compressed: Boolean = false,
val error: String? = null,
)
/**
* Default relay URLs for Nostr connectivity.
*/
object DefaultRelays {
val RELAYS =
listOf(
"wss://relay.damus.io",
"wss://relay.nostr.band",
"wss://nos.lol",
"wss://relay.snort.social",
"wss://nostr.wine",
)
}
@@ -0,0 +1,317 @@
/**
* 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.subscriptions
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("contacts-${pubKeyHex.take(8)}"),
filters = listOf(FilterBuilders.contactList(pubKeyHex)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
/**
* Creates a subscription config for fetching a specific note by ID.
*/
fun createNoteSubscription(
relays: Set<NormalizedRelayUrl>,
noteId: String,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig =
SubscriptionConfig(
subId = generateSubId("note-${noteId.take(8)}"),
filters = listOf(FilterBuilders.byIds(listOf(noteId))),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
/**
* Creates a subscription config for fetching all replies to a note (thread).
*
* @param noteId The root note ID to fetch replies for
* @param limit Maximum number of reply events to request
*/
fun createThreadRepliesSubscription(
relays: Set<NormalizedRelayUrl>,
noteId: String,
limit: Int = 200,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig =
SubscriptionConfig(
subId = generateSubId("thread-${noteId.take(8)}"),
filters =
listOf(
FilterBuilders.byETags(
eventIds = listOf(noteId),
kinds = listOf(1), // TextNoteEvent
limit = limit,
),
),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
/**
* Creates a NIP-50 search subscription for user profiles.
* Requires NIP-50 compatible relays (e.g., relay.nostr.band, nostr.wine).
*
* @param searchQuery Text to search for in user profiles
* @param limit Maximum results to return
*/
fun createSearchPeopleSubscription(
relays: Set<NormalizedRelayUrl>,
searchQuery: String,
limit: Int = 50,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig? {
if (searchQuery.isBlank()) return null
return SubscriptionConfig(
subId = generateSubId("search-people-${searchQuery.take(8)}"),
filters = listOf(FilterBuilders.searchPeople(searchQuery, limit)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
}
/**
* Creates a NIP-50 search subscription for text notes.
* Requires NIP-50 compatible relays.
*
* @param searchQuery Text to search for in notes
* @param limit Maximum results to return
*/
fun createSearchNotesSubscription(
relays: Set<NormalizedRelayUrl>,
searchQuery: String,
limit: Int = 50,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig? {
if (searchQuery.isBlank()) return null
return SubscriptionConfig(
subId = generateSubId("search-notes-${searchQuery.take(8)}"),
filters = listOf(FilterBuilders.searchNotes(searchQuery, limit)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
}
/**
* Creates a subscription for zap receipts (kind 9735) for specific events.
*
* @param eventIds Event IDs to get zaps for
* @param limit Maximum zaps per event
*/
fun createZapsSubscription(
relays: Set<NormalizedRelayUrl>,
eventIds: List<String>,
limit: Int = 100,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig? {
if (eventIds.isEmpty()) return null
return SubscriptionConfig(
subId = generateSubId("zaps-${eventIds.first().take(8)}"),
filters = listOf(FilterBuilders.zapsForEvents(eventIds, limit)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
}
/**
* Creates a subscription for reactions (kind 7) for specific events.
*
* @param eventIds Event IDs to get reactions for
* @param limit Maximum reactions per event
*/
fun createReactionsSubscription(
relays: Set<NormalizedRelayUrl>,
eventIds: List<String>,
limit: Int = 100,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig? {
if (eventIds.isEmpty()) return null
return SubscriptionConfig(
subId = generateSubId("reactions-${eventIds.first().take(8)}"),
filters = listOf(FilterBuilders.reactionsForEvents(eventIds, limit)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
}
/**
* Creates a subscription for replies (kind 1) to specific events.
*
* @param eventIds Event IDs to get replies for
* @param limit Maximum replies per event
*/
fun createRepliesSubscription(
relays: Set<NormalizedRelayUrl>,
eventIds: List<String>,
limit: Int = 100,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig? {
if (eventIds.isEmpty()) return null
return SubscriptionConfig(
subId = generateSubId("replies-${eventIds.first().take(8)}"),
filters = listOf(FilterBuilders.repliesForEvents(eventIds, limit)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
}
/**
* Creates a subscription for reposts (kind 6) of specific events.
*
* @param eventIds Event IDs to get reposts for
* @param limit Maximum reposts per event
*/
fun createRepostsSubscription(
relays: Set<NormalizedRelayUrl>,
eventIds: List<String>,
limit: Int = 100,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig? {
if (eventIds.isEmpty()) return null
return SubscriptionConfig(
subId = generateSubId("reposts-${eventIds.first().take(8)}"),
filters = listOf(FilterBuilders.repostsForEvents(eventIds, limit)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
}
/**
* Creates a subscription config for global long-form content (kind 30023, NIP-23).
*/
fun createLongFormFeedSubscription(
relays: Set<NormalizedRelayUrl>,
limit: Int = 30,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig =
SubscriptionConfig(
subId = generateSubId("longform-feed"),
filters = listOf(FilterBuilders.longFormGlobal(limit = limit)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
/**
* Creates a subscription config for long-form content from followed users.
*/
fun createFollowingLongFormFeedSubscription(
relays: Set<NormalizedRelayUrl>,
followedUsers: List<String>,
limit: Int = 30,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig? {
if (followedUsers.isEmpty()) return null
return SubscriptionConfig(
subId = generateSubId("longform-following"),
filters = listOf(FilterBuilders.longFormFromAuthors(followedUsers, limit = limit)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
}
@@ -0,0 +1,525 @@
/**
* 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.subscriptions
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 user metadata (kind 0) from multiple authors.
*
* @param pubKeyHexList List of author public keys (hex-encoded, 64 chars each)
* @return Filter for user metadata
*/
fun userMetadataBatch(pubKeyHexList: List<String>): Filter =
Filter(
kinds = listOf(0), // MetadataEvent.KIND
authors = pubKeyHexList,
)
/**
* 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,
)
/**
* Creates a NIP-50 search filter for user metadata (kind 0).
* Searches user profiles by name, displayName, about, nip05, etc.
* Requires a NIP-50 compatible relay (e.g., relay.nostr.band, nostr.wine).
*
* @param searchQuery The text to search for in user profiles
* @param limit Maximum number of results to return
* @return Filter for NIP-50 search
*/
fun searchPeople(
searchQuery: String,
limit: Int = 50,
): Filter =
Filter(
kinds = listOf(0), // MetadataEvent.KIND
search = searchQuery,
limit = limit,
)
/**
* Creates a NIP-50 search filter for text notes (kind 1).
* Searches note content.
* Requires a NIP-50 compatible relay.
*
* @param searchQuery The text to search for in notes
* @param limit Maximum number of results to return
* @return Filter for NIP-50 search
*/
fun searchNotes(
searchQuery: String,
limit: Int = 50,
): Filter =
Filter(
kinds = listOf(1), // TextNoteEvent.KIND
search = searchQuery,
limit = limit,
)
/**
* Creates a filter for zap receipts (kind 9735) for specific events.
*
* @param eventIds List of event IDs to get zaps for
* @param limit Maximum number of events to request
* @return Filter for zap receipts
*/
fun zapsForEvents(
eventIds: List<String>,
limit: Int? = null,
): Filter =
Filter(
kinds = listOf(9735), // LnZapEvent.KIND
tags = mapOf("e" to eventIds),
limit = limit,
)
/**
* Creates a filter for reactions (kind 7) for specific events.
*
* @param eventIds List of event IDs to get reactions for
* @param limit Maximum number of events to request
* @return Filter for reactions
*/
fun reactionsForEvents(
eventIds: List<String>,
limit: Int? = null,
): Filter =
Filter(
kinds = listOf(7), // ReactionEvent.KIND
tags = mapOf("e" to eventIds),
limit = limit,
)
/**
* Creates a filter for replies (kind 1) to specific events.
*
* @param eventIds List of event IDs to get replies for
* @param limit Maximum number of events to request
* @return Filter for replies
*/
fun repliesForEvents(
eventIds: List<String>,
limit: Int? = null,
): Filter =
Filter(
kinds = listOf(1), // TextNoteEvent.KIND
tags = mapOf("e" to eventIds),
limit = limit,
)
/**
* Creates a filter for reposts (kind 6) of specific events.
*
* @param eventIds List of event IDs to get reposts for
* @param limit Maximum number of events to request
* @return Filter for reposts
*/
fun repostsForEvents(
eventIds: List<String>,
limit: Int? = null,
): Filter =
Filter(
kinds = listOf(6), // RepostEvent.KIND
tags = mapOf("e" to eventIds),
limit = limit,
)
/**
* Creates a filter for long-form content (kind 30023, NIP-23).
*
* @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 long-form content
*/
fun longFormGlobal(
limit: Int? = null,
since: Long? = null,
until: Long? = null,
): Filter =
Filter(
kinds = listOf(30023), // LongTextNoteEvent.KIND
limit = limit,
since = since,
until = until,
)
/**
* Creates a filter for long-form content (kind 30023) 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 long-form content from specified authors
*/
fun longFormFromAuthors(
authors: List<String>,
limit: Int? = null,
since: Long? = null,
until: Long? = null,
): Filter =
Filter(
kinds = listOf(30023), // LongTextNoteEvent.KIND
authors = authors,
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 tagsAll: 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 tagAll(
name: String,
values: List<String>,
) {
if (tagsAll == null) tagsAll = mutableMapOf()
tagsAll!![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, tagsAll, since, until, limit, search)
}
/**
* Creates a filter using the DSL builder.
*
* Example:
* ```kotlin
* val filter = buildFilter {
* kinds(1)
* authors("pubkey1", "pubkey2")
* limit(50)
* }
* ```
*/
fun buildFilter(block: FilterBuilder.() -> Unit): Filter = FilterBuilder().apply(block).build()
@@ -0,0 +1,108 @@
/**
* 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.subscriptions
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).
* Returns null if the pubKeyHex is invalid (not 64 characters).
*/
fun createMetadataSubscription(
relays: Set<NormalizedRelayUrl>,
pubKeyHex: String,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig? {
// Validate pubkey length
if (pubKeyHex.length != 64) {
return null
}
return SubscriptionConfig(
subId = generateSubId("meta-${pubKeyHex.take(8)}"),
filters = listOf(FilterBuilders.userMetadata(pubKeyHex)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
}
/**
* Creates a subscription config for metadata of multiple users (kind 0).
* Useful for batch-fetching author profiles.
* Filters out any invalid pubkeys (not 64 characters).
*/
fun createBatchMetadataSubscription(
relays: Set<NormalizedRelayUrl>,
pubKeyHexList: List<String>,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig? {
// Filter out invalid pubkeys
val validPubkeys = pubKeyHexList.filter { it.length == 64 }
if (validPubkeys.isEmpty()) return null
return SubscriptionConfig(
subId = generateSubId("meta-batch-${validPubkeys.size}"),
filters = listOf(FilterBuilders.userMetadataBatch(validPubkeys)),
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("posts-${pubKeyHex.take(8)}"),
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("notif-${pubKeyHex.take(8)}"),
filters = listOf(FilterBuilders.notificationsForUser(pubKeyHex, limit = limit)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
@@ -0,0 +1,114 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.subscriptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.desktop.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()}"
@@ -43,8 +43,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.vitorpamplona.amethyst.commons.account.AccountState
import com.vitorpamplona.amethyst.commons.model.nip10TextNotes.PublishAction
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -51,7 +51,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.account.AccountState
import com.vitorpamplona.amethyst.desktop.account.AccountState
import java.awt.Toolkit
import java.awt.datatransfer.StringSelection
@@ -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.ui
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.desktop.ui.note.NoteDisplayData
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
import com.vitorpamplona.quartz.nip19Bech32.toNpub
/**
* Extension to convert Event to NoteDisplayData for the shared NoteCard.
*/
fun Event.toNoteDisplayData(cache: ICacheProvider? = null): NoteDisplayData {
val npub =
try {
pubKey.hexToByteArrayOrNull()?.toNpub() ?: pubKey.take(16) + "..."
} catch (e: Exception) {
pubKey.take(16) + "..."
}
val pictureUrl = (cache?.getUserIfExists(pubKey) as? User)?.profilePicture()
return NoteDisplayData(
id = id,
pubKeyHex = pubKey,
pubKeyDisplay = npub,
profilePictureUrl = pictureUrl,
content = content,
createdAt = createdAt,
)
}
@@ -53,27 +53,26 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.account.AccountState
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
import com.vitorpamplona.amethyst.commons.subscriptions.FeedMode
import com.vitorpamplona.amethyst.commons.subscriptions.FilterBuilders
import com.vitorpamplona.amethyst.commons.subscriptions.createBatchMetadataSubscription
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.createReactionsSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createRepliesSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createRepostsSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createZapsSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
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.DesktopPreferences
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode
import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders
import com.vitorpamplona.amethyst.desktop.subscriptions.createBatchMetadataSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createFollowingFeedSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createGlobalFeedSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createReactionsSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createRepliesSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createRepostsSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
@@ -38,10 +38,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.SharedRes
import com.vitorpamplona.amethyst.commons.account.AccountManager
import com.vitorpamplona.amethyst.commons.account.AccountState
import com.vitorpamplona.amethyst.commons.ui.auth.LoginCard
import com.vitorpamplona.amethyst.commons.ui.auth.NewKeyWarningCard
import com.vitorpamplona.amethyst.desktop.account.AccountManager
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.ui.auth.LoginCard
import com.vitorpamplona.amethyst.desktop.ui.auth.NewKeyWarningCard
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -55,7 +55,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.account.AccountState
import com.vitorpamplona.amethyst.commons.icons.Bookmark
import com.vitorpamplona.amethyst.commons.icons.BookmarkFilled
import com.vitorpamplona.amethyst.commons.icons.Reply
@@ -66,6 +65,7 @@ import com.vitorpamplona.amethyst.commons.model.nip25Reactions.ReactionAction
import com.vitorpamplona.amethyst.commons.model.nip51Bookmarks.BookmarkAction
import com.vitorpamplona.amethyst.commons.model.nip57Zaps.ZapAction
import com.vitorpamplona.amethyst.commons.services.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.nwc.NwcPaymentHandler
@@ -49,19 +49,19 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.account.AccountState
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.EmptyState
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.account.AccountState
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.subscriptions.createNotificationsSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
@@ -51,25 +51,24 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.account.AccountState
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
import com.vitorpamplona.amethyst.commons.subscriptions.FilterBuilders
import com.vitorpamplona.amethyst.commons.subscriptions.SubscriptionConfig
import com.vitorpamplona.amethyst.commons.subscriptions.createNoteSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createReactionsSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createRepliesSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createRepostsSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createThreadRepliesSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createZapsSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.note.NoteCard
import com.vitorpamplona.amethyst.commons.ui.thread.drawReplyLevel
import com.vitorpamplona.amethyst.commons.util.toNoteDisplayData
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders
import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
import com.vitorpamplona.amethyst.desktop.subscriptions.createNoteSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createReactionsSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createRepliesSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createRepostsSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createThreadRepliesSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
@@ -59,21 +59,21 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.account.AccountState
import com.vitorpamplona.amethyst.commons.model.nip02FollowList.FollowAction
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
import com.vitorpamplona.amethyst.commons.state.FollowState
import com.vitorpamplona.amethyst.commons.subscriptions.FilterBuilders
import com.vitorpamplona.amethyst.commons.subscriptions.SubscriptionConfig
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.UserAvatar
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders
import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createUserPostsSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
@@ -0,0 +1,130 @@
/**
* 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.ui.auth
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.SharedRes
import dev.icerock.moko.resources.compose.stringResource
/**
* Text field for entering Nostr keys (nsec or npub) with visibility toggle.
*/
@Composable
fun KeyInputField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
label: String = stringResource(SharedRes.strings.login_key_label),
placeholder: String = stringResource(SharedRes.strings.login_key_placeholder),
errorMessage: String? = null,
) {
var showKey by remember { mutableStateOf(false) }
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
placeholder = { Text(placeholder) },
modifier = modifier.fillMaxWidth(),
singleLine = true,
visualTransformation =
if (showKey) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
trailingIcon = {
IconButton(onClick = { showKey = !showKey }) {
Icon(
if (showKey) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = if (showKey) stringResource(SharedRes.strings.login_hide_key) else stringResource(SharedRes.strings.login_show_key),
)
}
},
isError = errorMessage != null,
supportingText =
errorMessage?.let {
{ Text(it, color = MaterialTheme.colorScheme.error) }
},
)
}
/**
* Card displaying a Nostr key that users can select/copy.
*/
@Composable
fun SelectableKeyText(
key: String,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier,
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
) {
Text(
text = key,
modifier = Modifier.padding(12.dp).fillMaxWidth(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
@Preview
@Composable
fun KeyInputFieldPreview() {
KeyInputField(
value = "nsec1example1234567890",
onValueChange = {},
)
}
@Preview
@Composable
fun SelectableKeyTextPreview() {
SelectableKeyText(
key = "npub1example1234567890abcdefghijklmnopqrstuvwxyz1234567890",
)
}
@@ -0,0 +1,145 @@
/**
* 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.ui.auth
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.SharedRes
import dev.icerock.moko.resources.compose.stringResource
/**
* Login card with Nostr key input field and action buttons.
*
* @param onLogin Callback when login is attempted with the key input
* @param onGenerateNew Callback when "Generate New" is clicked
* @param modifier Modifier for the card
* @param cardWidth Width of the card (default 400.dp)
* @param title Card title
* @param subtitle Subtitle/hint text
*/
@Composable
fun LoginCard(
onLogin: (String) -> Result<Unit>,
onGenerateNew: () -> Unit,
modifier: Modifier = Modifier,
cardWidth: Dp = 400.dp,
title: String = stringResource(SharedRes.strings.login_card_title),
subtitle: String = stringResource(SharedRes.strings.login_card_subtitle),
) {
var keyInput by remember { mutableStateOf("") }
var errorMessage by remember { mutableStateOf<String?>(null) }
Card(
modifier = modifier.width(cardWidth),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.height(16.dp))
KeyInputField(
value = keyInput,
onValueChange = {
keyInput = it
errorMessage = null
},
errorMessage = errorMessage,
)
Spacer(Modifier.height(8.dp))
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = {
onLogin(keyInput).fold(
onSuccess = { /* handled by caller */ },
onFailure = { errorMessage = it.message },
)
},
modifier = Modifier.weight(1f),
enabled = keyInput.isNotBlank(),
) {
Text(stringResource(SharedRes.strings.login_button))
}
OutlinedButton(
onClick = onGenerateNew,
modifier = Modifier.weight(1f),
) {
Text(stringResource(SharedRes.strings.login_generate_button))
}
}
}
}
}
@Preview
@Composable
fun LoginCardPreview() {
LoginCard(
onLogin = { Result.success(Unit) },
onGenerateNew = {},
)
}
@@ -0,0 +1,125 @@
/**
* 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.ui.auth
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.SharedRes
import dev.icerock.moko.resources.compose.stringResource
/**
* Warning card displayed after generating a new Nostr key pair.
* Reminds users to save their keys and shows both public and secret keys.
*
* @param npub The public key in npub format
* @param nsec The secret key in nsec format (nullable for read-only accounts)
* @param onContinue Callback when user acknowledges they've saved their keys
* @param modifier Modifier for the card
* @param cardWidth Width of the card (default 500.dp)
*/
@Composable
fun NewKeyWarningCard(
npub: String,
nsec: String?,
onContinue: () -> Unit,
modifier: Modifier = Modifier,
cardWidth: Dp = 500.dp,
) {
Card(
modifier = modifier.width(cardWidth),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f),
),
) {
Column(
modifier = Modifier.padding(24.dp),
) {
Text(
stringResource(SharedRes.strings.new_key_warning_title),
style = MaterialTheme.typography.titleMedium,
color = Color.Red,
)
Spacer(Modifier.height(16.dp))
Text(
stringResource(SharedRes.strings.new_key_warning_message),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.height(16.dp))
Text(
stringResource(SharedRes.strings.new_key_public_label),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
SelectableKeyText(npub)
Spacer(Modifier.height(12.dp))
nsec?.let { secretKey ->
Text(
stringResource(SharedRes.strings.new_key_secret_label),
style = MaterialTheme.typography.labelMedium,
color = Color.Red,
)
SelectableKeyText(secretKey)
}
Spacer(Modifier.height(24.dp))
Button(
onClick = onContinue,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(SharedRes.strings.new_key_continue_button))
}
}
}
}
@Preview
@Composable
fun NewKeyWarningCardPreview() {
NewKeyWarningCard(
npub = "npub1example1234567890abcdefghijklmnopqrstuvwxyz",
nsec = "nsec1example1234567890abcdefghijklmnopqrstuvwxyz",
onContinue = {},
)
}
@@ -0,0 +1,213 @@
/**
* 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.ui.note
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
/**
* Data class for displaying a note card.
*/
data class NoteDisplayData(
val id: String,
val pubKeyHex: String,
val pubKeyDisplay: String,
val profilePictureUrl: String? = null,
val content: String,
val createdAt: Long,
)
/**
* Reusable note card composable that displays a Nostr note.
* Can be used by both Desktop and Android apps.
*/
@Composable
fun NoteCard(
note: NoteDisplayData,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
onAuthorClick: ((String) -> Unit)? = null,
) {
val richTextParser = remember { RichTextParser() }
val urls = remember(note.content) { richTextParser.parseValidUrls(note.content) }
Card(
modifier = modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
onClick = onClick ?: {},
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
// Author with avatar
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
if (onAuthorClick != null) {
Modifier.clickable { onAuthorClick(note.pubKeyHex) }
} else {
Modifier
},
) {
UserAvatar(
userHex = note.pubKeyHex,
pictureUrl = note.profilePictureUrl,
size = 32.dp,
contentDescription = "Profile picture of ${note.pubKeyDisplay}",
)
Spacer(Modifier.width(8.dp))
Text(
text = note.pubKeyDisplay.take(20) + if (note.pubKeyDisplay.length > 20) "..." else "",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
)
}
// Timestamp
Text(
text = note.createdAt.toTimeAgo(withDot = false),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(8.dp))
RichTextContent(
content = note.content,
urls = urls,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f))
Spacer(Modifier.height(4.dp))
// Event ID (truncated)
Text(
text = "ID: ${note.id.take(12)}...",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
}
}
}
/**
* Renders text content with highlighted URLs.
* Uses RichTextParser from commons to detect and highlight links.
*/
@Composable
fun RichTextContent(
content: String,
urls: Set<String>,
modifier: Modifier = Modifier,
maxLines: Int = 10,
) {
if (urls.isEmpty()) {
Text(
text = content,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = maxLines,
overflow = TextOverflow.Ellipsis,
modifier = modifier,
)
} else {
val annotatedText =
buildAnnotatedString {
var lastIndex = 0
val sortedUrls = urls.sortedBy { content.indexOf(it) }
for (url in sortedUrls) {
val startIndex = content.indexOf(url, lastIndex)
if (startIndex == -1) continue
// Add text before URL
if (startIndex > lastIndex) {
append(content.substring(lastIndex, startIndex))
}
// Add URL with styling
withStyle(
SpanStyle(
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline,
),
) {
append(url)
}
lastIndex = startIndex + url.length
}
// Add remaining text
if (lastIndex < content.length) {
append(content.substring(lastIndex))
}
}
Text(
text = annotatedText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = maxLines,
overflow = TextOverflow.Ellipsis,
modifier = modifier,
)
}
}
@@ -0,0 +1,90 @@
/**
* 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.ui.profile
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
/**
* Card displaying Nostr account key information.
* Shows public key (npub), hex format, and optional read-only indicator.
*/
@Composable
fun ProfileInfoCard(
npub: String,
pubKeyHex: String,
isReadOnly: Boolean,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(modifier = Modifier.padding(16.dp)) {
if (isReadOnly) {
Text(
"Read-only mode",
style = MaterialTheme.typography.labelMedium,
color = Color.Yellow,
)
Spacer(Modifier.height(8.dp))
}
Text(
"Public Key",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
npub,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.height(16.dp))
Text(
"Hex",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
pubKeyHex,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
@@ -0,0 +1,133 @@
/**
* 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.ui.relay
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.desktop.network.RelayStatus
/**
* Card displaying the status of a Nostr relay connection.
* Shows connection status, ping time, compression, and errors.
*/
@Composable
fun RelayStatusCard(
status: RelayStatus,
onRemove: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.weight(1f),
) {
val statusColor =
when {
status.connected -> Color.Green
status.error != null -> Color.Red
else -> Color.Gray
}
if (status.connected) {
Icon(
Icons.Default.Check,
contentDescription = "Connected",
tint = statusColor,
modifier = Modifier.size(20.dp),
)
} else if (status.error != null) {
Icon(
Icons.Default.Close,
contentDescription = "Error",
tint = statusColor,
modifier = Modifier.size(20.dp),
)
} else {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
)
}
Column {
Text(
status.url.url,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
if (status.connected && status.pingMs != null) {
Text(
"${status.pingMs}ms${if (status.compressed) " • compressed" else ""}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
status.error?.let { error ->
Text(
error,
style = MaterialTheme.typography.bodySmall,
color = Color.Red.copy(alpha = 0.8f),
)
}
}
}
IconButton(onClick = onRemove) {
Icon(
Icons.Default.Close,
contentDescription = "Remove relay",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@@ -0,0 +1,510 @@
/**
* 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.filters
import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders
import com.vitorpamplona.amethyst.desktop.subscriptions.buildFilter
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.isEmpty())
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.isEmpty())
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.isEmpty())
assertTrue(!postsFilter.isEmpty())
assertTrue(!contactListFilter.isEmpty())
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.isEmpty())
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.isEmpty())
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.isEmpty())
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.isEmpty())
assertEquals(listOf(1), filter.kinds)
assertNotNull(filter.tags)
assertEquals(listOf("bitcoin"), filter.tags!!["t"])
}
}
@@ -44,7 +44,7 @@ class DesktopRelayConnectionManagerTest {
fun testRelayConnectionManagerInheritsFromBaseClass() {
val manager = DesktopRelayConnectionManager()
assertTrue(
manager is com.vitorpamplona.amethyst.commons.network.RelayConnectionManager,
manager is RelayConnectionManager,
"DesktopRelayConnectionManager should extend RelayConnectionManager",
)
}