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:
@@ -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
|
||||
|
||||
+274
@@ -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")
|
||||
}
|
||||
}
|
||||
-1
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
+226
@@ -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
|
||||
}
|
||||
}
|
||||
+49
@@ -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",
|
||||
)
|
||||
}
|
||||
+317
@@ -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,
|
||||
)
|
||||
}
|
||||
+525
@@ -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()
|
||||
+108
@@ -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,
|
||||
)
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.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()}"
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.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
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+11
-12
@@ -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
|
||||
|
||||
+7
-7
@@ -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
|
||||
|
||||
+130
@@ -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",
|
||||
)
|
||||
}
|
||||
+145
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
+125
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+90
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+133
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+510
@@ -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"])
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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",
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user