Moves some of the stuff that was saved in Commons back to the desktop app because it is only used there

This commit is contained in:
Vitor Pamplona
2026-01-13 16:04:02 -05:00
parent 73fe985a65
commit 09d0f4425c
25 changed files with 57 additions and 58 deletions
@@ -1,228 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.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 kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
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()
/**
* 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
// 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(): java.io.File {
val homeDir = System.getProperty("user.home")
return java.io.File(homeDir, ".amethyst/last_account.txt")
}
}
@@ -1,168 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.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)
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.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)
}
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
}
}
@@ -1,49 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.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",
)
}
@@ -1,133 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.subscriptions
import com.vitorpamplona.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,
)
@@ -1,364 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.subscriptions
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
/**
* Type-safe builders for common Nostr filter patterns.
* Provides convenience functions for creating relay subscription filters.
*/
object FilterBuilders {
/**
* Creates a filter for text notes (kind 1) from all authors.
*
* @param limit Maximum number of events to request
* @param since Timestamp for events with publication time ≥ this value
* @param until Timestamp for events with publication time ≤ this value
* @return Filter for global text notes
*/
fun textNotesGlobal(
limit: Int? = null,
since: Long? = null,
until: Long? = null,
): Filter =
Filter(
kinds = listOf(1), // TextNoteEvent.KIND
limit = limit,
since = since,
until = until,
)
/**
* Creates a filter for text notes (kind 1) from specific authors.
*
* @param authors List of author public keys (hex-encoded, 64 chars each)
* @param limit Maximum number of events to request
* @param since Timestamp for events with publication time ≥ this value
* @param until Timestamp for events with publication time ≤ this value
* @return Filter for text notes from specified authors
*/
fun textNotesFromAuthors(
authors: List<String>,
limit: Int? = null,
since: Long? = null,
until: Long? = null,
): Filter =
Filter(
kinds = listOf(1), // TextNoteEvent.KIND
authors = authors,
limit = limit,
since = since,
until = until,
)
/**
* Creates a filter for user metadata (kind 0) from a specific author.
*
* @param pubKeyHex Author public key (hex-encoded, 64 chars)
* @return Filter for user metadata (limit=1 since only latest is needed)
*/
fun userMetadata(pubKeyHex: String): Filter =
Filter(
kinds = listOf(0), // MetadataEvent.KIND
authors = listOf(pubKeyHex),
limit = 1,
)
/**
* Creates a filter for contact list (kind 3) from a specific author.
*
* @param pubKeyHex Author public key (hex-encoded, 64 chars)
* @return Filter for contact list (limit=1 since only latest is needed)
*/
fun contactList(pubKeyHex: String): Filter =
Filter(
kinds = listOf(3), // ContactListEvent.KIND
authors = listOf(pubKeyHex),
limit = 1,
)
/**
* Creates a filter for notifications (mentions, replies, reactions, reposts, zaps) for a user.
*
* Includes:
* - kind 1 (text notes mentioning user)
* - kind 7 (reactions)
* - kind 6 (reposts)
* - kind 16 (generic reposts)
* - kind 9735 (zaps)
*
* @param pubKeyHex User public key (hex-encoded, 64 chars) to filter notifications for
* @param limit Maximum number of events to request
* @param since Timestamp for events with publication time ≥ this value
* @return Filter for notifications targeting the specified user
*/
fun notificationsForUser(
pubKeyHex: String,
limit: Int? = null,
since: Long? = null,
): Filter =
Filter(
kinds =
listOf(
1, // TextNoteEvent.KIND (mentions/replies)
7, // ReactionEvent.KIND
6, // RepostEvent.KIND
16, // GenericRepostEvent.KIND
9735, // LnZapEvent.KIND
),
tags = mapOf("p" to listOf(pubKeyHex)),
limit = limit,
since = since,
)
/**
* Creates a filter for specific event kinds.
*
* @param kinds List of event kinds to filter
* @param limit Maximum number of events to request
* @param since Timestamp for events with publication time ≥ this value
* @param until Timestamp for events with publication time ≤ this value
* @return Filter for specified event kinds
*/
fun byKinds(
kinds: List<Int>,
limit: Int? = null,
since: Long? = null,
until: Long? = null,
): Filter =
Filter(
kinds = kinds,
limit = limit,
since = since,
until = until,
)
/**
* Creates a filter for events from specific authors.
*
* @param authors List of author public keys (hex-encoded, 64 chars each)
* @param kinds Optional list of event kinds to filter (if null, all kinds)
* @param limit Maximum number of events to request
* @param since Timestamp for events with publication time ≥ this value
* @param until Timestamp for events with publication time ≤ this value
* @return Filter for events from specified authors
*/
fun byAuthors(
authors: List<String>,
kinds: List<Int>? = null,
limit: Int? = null,
since: Long? = null,
until: Long? = null,
): Filter =
Filter(
authors = authors,
kinds = kinds,
limit = limit,
since = since,
until = until,
)
/**
* Creates a filter for events with specific event IDs.
*
* @param ids List of event IDs (hex-encoded, 64 chars each)
* @return Filter for specified event IDs
*/
fun byIds(ids: List<String>): Filter =
Filter(
ids = ids,
)
/**
* Creates a filter for events tagged with specific p-tags (mentioning users).
*
* @param pubKeys List of public keys (hex-encoded, 64 chars each) to filter by
* @param kinds Optional list of event kinds to filter
* @param limit Maximum number of events to request
* @param since Timestamp for events with publication time ≥ this value
* @return Filter for events mentioning specified users
*/
fun byPTags(
pubKeys: List<String>,
kinds: List<Int>? = null,
limit: Int? = null,
since: Long? = null,
): Filter =
Filter(
tags = mapOf("p" to pubKeys),
kinds = kinds,
limit = limit,
since = since,
)
/**
* Creates a filter for events tagged with specific e-tags (referencing events).
*
* @param eventIds List of event IDs (hex-encoded, 64 chars each) to filter by
* @param kinds Optional list of event kinds to filter
* @param limit Maximum number of events to request
* @param since Timestamp for events with publication time ≥ this value
* @return Filter for events referencing specified events
*/
fun byETags(
eventIds: List<String>,
kinds: List<Int>? = null,
limit: Int? = null,
since: Long? = null,
): Filter =
Filter(
tags = mapOf("e" to eventIds),
kinds = kinds,
limit = limit,
since = since,
)
/**
* Creates a filter for events with custom tag filters.
*
* @param tags Map of tag names to value lists (e.g., {"p": ["pubkey1"], "t": ["bitcoin"]})
* @param kinds Optional list of event kinds to filter
* @param limit Maximum number of events to request
* @param since Timestamp for events with publication time ≥ this value
* @param until Timestamp for events with publication time ≤ this value
* @return Filter with custom tag criteria
*/
fun byTags(
tags: Map<String, List<String>>,
kinds: List<Int>? = null,
limit: Int? = null,
since: Long? = null,
until: Long? = null,
): Filter =
Filter(
tags = tags,
kinds = kinds,
limit = limit,
since = since,
until = until,
)
}
/**
* DSL builder for creating custom filters with a fluent API.
*
* Example:
* ```kotlin
* val filter = buildFilter {
* kinds(1, 7)
* authors("pubkey1", "pubkey2")
* limit(50)
* since(System.currentTimeMillis() / 1000 - 86400) // Last 24 hours
* }
* ```
*/
class FilterBuilder {
private var ids: List<String>? = null
private var authors: List<String>? = null
private var kinds: List<Int>? = null
private var tags: MutableMap<String, List<String>>? = null
private var 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()
@@ -1,78 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.subscriptions
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* Creates a subscription config for user metadata (kind 0).
*/
fun createMetadataSubscription(
relays: Set<NormalizedRelayUrl>,
pubKeyHex: String,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig =
SubscriptionConfig(
subId = generateSubId("meta-${pubKeyHex.take(8)}"),
filters = listOf(FilterBuilders.userMetadata(pubKeyHex)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
/**
* Creates a subscription config for user posts (kind 1).
*/
fun createUserPostsSubscription(
relays: Set<NormalizedRelayUrl>,
pubKeyHex: String,
limit: Int = 50,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig =
SubscriptionConfig(
subId = generateSubId("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,
)
@@ -1,114 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.subscriptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.network.RelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* Represents an active relay subscription that can be unsubscribed.
*/
data class SubscriptionHandle(
val subId: String,
val unsubscribe: () -> Unit,
)
/**
* Configuration for a relay subscription.
*/
data class SubscriptionConfig(
val subId: String,
val filters: List<Filter>,
val relays: Set<NormalizedRelayUrl>,
val onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
val onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
)
/**
* Composable that remembers a subscription and automatically unsubscribes on dispose.
*
* Usage:
* ```kotlin
* rememberSubscription(relayStatuses, pubKeyHex) {
* SubscriptionConfig(
* subId = "my-sub-${System.currentTimeMillis()}",
* filters = listOf(Filter(kinds = listOf(1), limit = 50)),
* relays = relayStatuses.keys,
* onEvent = { event, _, _, _ -> events.add(event) }
* )
* }
* ```
*/
@Composable
fun rememberSubscription(
vararg keys: Any?,
relayManager: RelayConnectionManager,
config: () -> SubscriptionConfig?,
): SubscriptionHandle? {
val subscription = remember(*keys) { config() }
DisposableEffect(*keys, subscription?.subId) {
subscription?.let { cfg ->
if (cfg.relays.isNotEmpty()) {
relayManager.subscribe(
subId = cfg.subId,
filters = cfg.filters,
relays = cfg.relays,
listener =
object : IRequestListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
cfg.onEvent(event, isLive, relay, forFilters)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
cfg.onEose(relay, forFilters)
}
},
)
}
}
onDispose {
subscription?.let { relayManager.unsubscribe(it.subId) }
}
}
return subscription?.let { SubscriptionHandle(it.subId, { relayManager.unsubscribe(it.subId) }) }
}
/**
* Generates a unique subscription ID with timestamp.
*/
fun generateSubId(prefix: String): String = "$prefix-${System.currentTimeMillis()}"
@@ -1,130 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.auth
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
import org.jetbrains.compose.ui.tooling.preview.Preview
/**
* 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",
)
}
@@ -1,145 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.auth
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
import org.jetbrains.compose.ui.tooling.preview.Preview
/**
* 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 = {},
)
}
@@ -1,125 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.auth
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
import org.jetbrains.compose.ui.tooling.preview.Preview
/**
* 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 = {},
)
}
@@ -1,213 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.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,
)
}
}
@@ -1,90 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.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,
)
}
}
}
@@ -1,133 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.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.commons.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,
)
}
}
}
}
@@ -1,46 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.util
import com.vitorpamplona.amethyst.commons.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(): NoteDisplayData {
val npub =
try {
pubKey.hexToByteArrayOrNull()?.toNpub() ?: pubKey.take(16) + "..."
} catch (e: Exception) {
pubKey.take(16) + "..."
}
return NoteDisplayData(
id = id,
pubKeyHex = pubKey,
pubKeyDisplay = npub,
content = content,
createdAt = createdAt,
)
}