diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
index 80441bbb1..3e220e0ac 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
@@ -25,6 +25,7 @@ import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.cache.IChannel
+import com.vitorpamplona.amethyst.commons.services.nwc.NwcPaymentTracker
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
@@ -225,7 +226,7 @@ object LocalCache : ILocalCache, ICacheProvider {
val liveChatChannels = LargeCache
()
val ephemeralChannels = LargeCache()
- val awaitingPaymentRequests = ConcurrentHashMap Unit>>(10)
+ val paymentTracker = NwcPaymentTracker()
val relayHints = HintIndexer()
@@ -308,7 +309,7 @@ object LocalCache : ILocalCache, ICacheProvider {
fun load(keys: Set): Set = keys.mapNotNullTo(mutableSetOf(), ::checkGetOrCreateUser)
- fun getOrCreateUser(key: HexKey): User {
+ override fun getOrCreateUser(key: HexKey): User {
require(isValidHex(key = key)) { "$key is not a valid hex" }
return users.getOrCreate(key) {
@@ -1976,7 +1977,7 @@ object LocalCache : ILocalCache, ICacheProvider {
zappedNote?.addZapPayment(note, null)
- awaitingPaymentRequests.put(event.id, Pair(zappedNote, onResponse))
+ paymentTracker.registerRequest(event.id, zappedNote, onResponse)
refreshNewNoteObservers(note)
@@ -1992,9 +1993,10 @@ object LocalCache : ILocalCache, ICacheProvider {
wasVerified: Boolean,
): Boolean {
val requestId = event.requestId()
- val pair = awaitingPaymentRequests[requestId] ?: return false
+ val pending = paymentTracker.onResponseReceived(requestId) ?: return false
- val (zappedNote, responseCallback) = pair
+ val zappedNote = pending.zappedNote
+ val responseCallback = pending.onResponse
val requestNote = requestId?.let { checkGetOrCreateNote(requestId) }
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Bookmark.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Bookmark.kt
new file mode 100644
index 000000000..368c22e12
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Bookmark.kt
@@ -0,0 +1,121 @@
+/**
+ * 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.icons
+
+import androidx.compose.foundation.Image
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.PathFillType.Companion.NonZero
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.StrokeCap.Companion.Butt
+import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.graphics.vector.path
+import androidx.compose.ui.unit.dp
+import org.jetbrains.compose.ui.tooling.preview.Preview
+
+@Preview
+@Composable
+private fun VectorPreview() {
+ Image(Bookmark, null)
+}
+
+@Preview
+@Composable
+private fun FilledPreview() {
+ Image(BookmarkFilled, null)
+}
+
+private var bookmark: ImageVector? = null
+private var bookmarkFilled: ImageVector? = null
+
+val Bookmark: ImageVector
+ get() =
+ bookmark ?: ImageVector
+ .Builder(
+ name = "Bookmark",
+ defaultWidth = 24.0.dp,
+ defaultHeight = 24.0.dp,
+ viewportWidth = 24.0f,
+ viewportHeight = 24.0f,
+ ).apply {
+ path(
+ fill = SolidColor(Color(0xFF000000)),
+ stroke = null,
+ strokeLineWidth = 0.0f,
+ strokeLineCap = Butt,
+ strokeLineJoin = Miter,
+ strokeLineMiter = 4.0f,
+ pathFillType = NonZero,
+ ) {
+ // Material bookmark outline
+ moveTo(17.0f, 3.0f)
+ horizontalLineTo(7.0f)
+ curveToRelative(-1.1f, 0.0f, -2.0f, 0.9f, -2.0f, 2.0f)
+ verticalLineToRelative(16.0f)
+ lineToRelative(7.0f, -3.0f)
+ lineToRelative(7.0f, 3.0f)
+ verticalLineTo(5.0f)
+ curveToRelative(0.0f, -1.1f, -0.9f, -2.0f, -2.0f, -2.0f)
+ close()
+ moveTo(17.0f, 18.0f)
+ lineToRelative(-5.0f, -2.18f)
+ lineTo(7.0f, 18.0f)
+ verticalLineTo(5.0f)
+ horizontalLineToRelative(10.0f)
+ verticalLineToRelative(13.0f)
+ close()
+ }
+ }.build()
+ .also { bookmark = it }
+
+val BookmarkFilled: ImageVector
+ get() =
+ bookmarkFilled ?: ImageVector
+ .Builder(
+ name = "BookmarkFilled",
+ defaultWidth = 24.0.dp,
+ defaultHeight = 24.0.dp,
+ viewportWidth = 24.0f,
+ viewportHeight = 24.0f,
+ ).apply {
+ path(
+ fill = SolidColor(Color(0xFF000000)),
+ stroke = null,
+ strokeLineWidth = 0.0f,
+ strokeLineCap = Butt,
+ strokeLineJoin = Miter,
+ strokeLineMiter = 4.0f,
+ pathFillType = NonZero,
+ ) {
+ // Material bookmark filled
+ moveTo(17.0f, 3.0f)
+ horizontalLineTo(7.0f)
+ curveToRelative(-1.1f, 0.0f, -2.0f, 0.9f, -2.0f, 2.0f)
+ verticalLineToRelative(16.0f)
+ lineToRelative(7.0f, -3.0f)
+ lineToRelative(7.0f, 3.0f)
+ verticalLineTo(5.0f)
+ curveToRelative(0.0f, -1.1f, -0.9f, -2.0f, -2.0f, -2.0f)
+ close()
+ }
+ }.build()
+ .also { bookmarkFilled = it }
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.kt
index 7d1f32f98..d73085e9f 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.kt
@@ -96,6 +96,28 @@ interface ICacheProvider {
* @return true if the event has been deleted, false otherwise
*/
fun hasBeenDeleted(event: Any): Boolean
+
+ /**
+ * Finds users whose name, displayName, nip05, or lud16 starts with the given prefix.
+ * Used by search functionality to find users by name.
+ *
+ * @param prefix The search prefix to match against user names
+ * @param limit Maximum number of results to return
+ * @return List of Users matching the prefix
+ */
+ fun findUsersStartingWith(
+ prefix: String,
+ limit: Int = 50,
+ ): List = emptyList()
+
+ /**
+ * Gets or creates a User by public key hex.
+ * Used when processing events that reference users.
+ *
+ * @param pubkey The user's public key in hex format
+ * @return The User (existing or newly created)
+ */
+ fun getOrCreateUser(pubkey: HexKey): Any?
}
/**
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchParser.kt
new file mode 100644
index 000000000..cbd899ef9
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchParser.kt
@@ -0,0 +1,127 @@
+/**
+ * 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.search
+
+import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
+import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
+import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
+import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
+import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
+import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
+import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
+import com.vitorpamplona.quartz.utils.Hex
+
+/**
+ * Parses search input and returns matching results.
+ * Supports: npub, nprofile, nsec (extracts pubkey), note, nevent, naddr, hex keys, hashtags
+ *
+ * Shared between Android and Desktop for consistent Bech32 parsing.
+ */
+fun parseSearchInput(input: String): List {
+ if (input.isBlank()) return emptyList()
+
+ val trimmed = input.trim()
+ val results = mutableListOf()
+
+ // Check for hashtag
+ if (trimmed.startsWith("#") && trimmed.length > 1) {
+ results.add(SearchResult.HashtagResult(trimmed.substring(1)))
+ return results
+ }
+
+ // Try to parse as Bech32 (npub, nevent, naddr, etc.)
+ val parsed = Nip19Parser.uriToRoute(trimmed)?.entity
+ if (parsed != null) {
+ when (parsed) {
+ is NPub -> {
+ results.add(
+ SearchResult.UserResult(
+ pubKeyHex = parsed.hex,
+ displayId = trimmed.take(20) + "...",
+ ),
+ )
+ }
+ is NProfile -> {
+ results.add(
+ SearchResult.UserResult(
+ pubKeyHex = parsed.hex,
+ displayId = trimmed.take(20) + "...",
+ ),
+ )
+ }
+ is NSec -> {
+ results.add(
+ SearchResult.UserResult(
+ pubKeyHex = parsed.toPubKeyHex(),
+ displayId = "User from nsec",
+ ),
+ )
+ }
+ is NNote -> {
+ results.add(
+ SearchResult.NoteResult(
+ noteIdHex = parsed.hex,
+ displayId = trimmed.take(20) + "...",
+ ),
+ )
+ }
+ is NEvent -> {
+ results.add(
+ SearchResult.NoteResult(
+ noteIdHex = parsed.hex,
+ displayId = trimmed.take(20) + "...",
+ ),
+ )
+ }
+ is NAddress -> {
+ results.add(
+ SearchResult.AddressResult(
+ kind = parsed.kind,
+ pubKeyHex = parsed.author,
+ dTag = parsed.dTag,
+ displayId = trimmed.take(20) + "...",
+ ),
+ )
+ }
+ else -> { }
+ }
+ return results
+ }
+
+ // Try to parse as hex (64-char pubkey or event id)
+ if (trimmed.length == 64 && Hex.isHex(trimmed)) {
+ results.add(
+ SearchResult.UserResult(
+ pubKeyHex = trimmed,
+ displayId = trimmed.take(16) + "..." + trimmed.takeLast(8),
+ ),
+ )
+ results.add(
+ SearchResult.NoteResult(
+ noteIdHex = trimmed,
+ displayId = trimmed.take(16) + "..." + trimmed.takeLast(8),
+ ),
+ )
+ return results
+ }
+
+ return results
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResult.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResult.kt
new file mode 100644
index 000000000..d8260354c
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResult.kt
@@ -0,0 +1,69 @@
+/**
+ * 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.search
+
+import com.vitorpamplona.amethyst.commons.model.User
+
+/**
+ * Represents a parsed search result from Bech32/hex input.
+ * Shared between Android and Desktop for consistent search behavior.
+ */
+sealed class SearchResult {
+ /**
+ * Direct user lookup from npub, nprofile, nsec, or hex pubkey.
+ */
+ data class UserResult(
+ val pubKeyHex: String,
+ val displayId: String,
+ ) : SearchResult()
+
+ /**
+ * User from local cache with full metadata.
+ */
+ data class CachedUserResult(
+ val user: User,
+ ) : SearchResult()
+
+ /**
+ * Note lookup from note1 or nevent.
+ */
+ data class NoteResult(
+ val noteIdHex: String,
+ val displayId: String,
+ ) : SearchResult()
+
+ /**
+ * Addressable event lookup from naddr.
+ */
+ data class AddressResult(
+ val kind: Int,
+ val pubKeyHex: String,
+ val dTag: String,
+ val displayId: String,
+ ) : SearchResult()
+
+ /**
+ * Hashtag search.
+ */
+ data class HashtagResult(
+ val hashtag: String,
+ ) : SearchResult()
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/EventCollectionState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/EventCollectionState.kt
index daeabe2fe..d2d4a0003 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/EventCollectionState.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/EventCollectionState.kt
@@ -90,6 +90,7 @@ class EventCollectionState(
mutex.withLock {
val itemId = getId(item)
if (itemId !in seenIds) {
+ seenIds.add(itemId)
pendingItems.add(item)
scheduleBatchUpdate()
}
@@ -108,6 +109,7 @@ class EventCollectionState(
mutex.withLock {
val newItems = items.filter { getId(it) !in seenIds }
if (newItems.isNotEmpty()) {
+ newItems.forEach { seenIds.add(getId(it)) }
pendingItems.addAll(newItems)
scheduleBatchUpdate()
}
@@ -191,8 +193,7 @@ class EventCollectionState(
mutex.withLock {
if (pendingItems.isEmpty()) return
- // Add pending IDs to seenIds
- pendingItems.forEach { seenIds.add(getId(it)) }
+ // seenIds already updated in addItem/addItems
// Merge with existing items
val merged = _items.value + pendingItems
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt
index fc9ef4ddc..df2122c58 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.commons.ui.components
import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Face
@@ -62,8 +63,10 @@ fun UserAvatar(
loadRobohash: Boolean = true,
) {
val avatarModifier =
- remember(size) {
- modifier.clip(shape = CircleShape)
+ remember(size, modifier) {
+ modifier
+ .size(size)
+ .clip(shape = CircleShape)
}
if (pictureUrl != null && loadProfilePicture) {
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt
new file mode 100644
index 000000000..d2df0abeb
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt
@@ -0,0 +1,105 @@
+/**
+ * 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.components
+
+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.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowForward
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+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.text.font.FontFamily
+import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.commons.model.User
+
+/**
+ * A card displaying user search result with avatar, name, and nip05/pubkey.
+ * Shared between Android and Desktop search screens.
+ */
+@Composable
+fun UserSearchCard(
+ user: User,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Card(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick),
+ colors =
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant,
+ ),
+ ) {
+ Row(
+ modifier = Modifier.padding(12.dp).fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ UserAvatar(
+ userHex = user.pubkeyHex,
+ pictureUrl = user.profilePicture(),
+ size = 40.dp,
+ contentDescription = "Profile picture",
+ )
+
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ user.toBestDisplayName(),
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ val nip05 = user.nip05()
+ if (nip05 != null) {
+ Text(
+ nip05,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ } else {
+ Text(
+ user.pubkeyDisplayHex(),
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+
+ Icon(
+ Icons.AutoMirrored.Filled.ArrowForward,
+ contentDescription = "Navigate",
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/SearchBarState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/SearchBarState.kt
new file mode 100644
index 000000000..6e873eae4
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/SearchBarState.kt
@@ -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.commons.viewmodels
+
+import com.vitorpamplona.amethyst.commons.model.User
+import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
+import com.vitorpamplona.amethyst.commons.search.SearchResult
+import com.vitorpamplona.amethyst.commons.search.parseSearchInput
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+
+/**
+ * State holder for search bar functionality.
+ * Shared between Android and Desktop search screens.
+ *
+ * Handles:
+ * - Search text input
+ * - Bech32/hex parsing
+ * - Local cache search (with debounce)
+ * - Relay search results aggregation
+ *
+ * Platform-specific concerns (relay subscriptions, navigation) remain in the UI layer.
+ */
+class SearchBarState(
+ private val cache: ICacheProvider,
+ private val scope: CoroutineScope,
+ private val debounceMs: Long = 300L,
+) {
+ private val _searchText = MutableStateFlow("")
+ val searchText: StateFlow = _searchText.asStateFlow()
+
+ private val _bech32Results = MutableStateFlow>(emptyList())
+ val bech32Results: StateFlow> = _bech32Results.asStateFlow()
+
+ private val _cachedUserResults = MutableStateFlow>(emptyList())
+ val cachedUserResults: StateFlow> = _cachedUserResults.asStateFlow()
+
+ private val _relaySearchResults = MutableStateFlow>(emptyList())
+ val relaySearchResults: StateFlow> = _relaySearchResults.asStateFlow()
+
+ private val _isSearchingRelays = MutableStateFlow(false)
+ val isSearchingRelays: StateFlow = _isSearchingRelays.asStateFlow()
+
+ val hasResults: Boolean
+ get() =
+ _bech32Results.value.isNotEmpty() ||
+ _cachedUserResults.value.isNotEmpty() ||
+ _relaySearchResults.value.isNotEmpty()
+
+ val shouldSearchRelays: Boolean
+ get() =
+ _searchText.value.length >= 2 &&
+ _bech32Results.value.isEmpty() &&
+ _cachedUserResults.value.size < 5
+
+ init {
+ setupSearchTextObserver()
+ }
+
+ @OptIn(FlowPreview::class)
+ private fun setupSearchTextObserver() {
+ // Debounced cache search
+ _searchText
+ .debounce(debounceMs)
+ .onEach { query ->
+ if (query.length >= 2 && _bech32Results.value.isEmpty()) {
+ @Suppress("UNCHECKED_CAST")
+ _cachedUserResults.value = cache.findUsersStartingWith(query, 20) as List
+ } else {
+ _cachedUserResults.value = emptyList()
+ }
+ }.launchIn(scope)
+ }
+
+ fun updateSearchText(text: String) {
+ _searchText.value = text
+ _relaySearchResults.value = emptyList()
+ _isSearchingRelays.value = false
+
+ // Parse Bech32/hex immediately (no debounce)
+ _bech32Results.value = parseSearchInput(text)
+
+ // Clear cached results if query too short or is bech32
+ if (text.length < 2 || _bech32Results.value.isNotEmpty()) {
+ _cachedUserResults.value = emptyList()
+ }
+ }
+
+ fun clearSearch() {
+ updateSearchText("")
+ }
+
+ fun startRelaySearch() {
+ _isSearchingRelays.value = true
+ }
+
+ fun endRelaySearch() {
+ _isSearchingRelays.value = false
+ }
+
+ fun addRelaySearchResult(user: User) {
+ if (!_relaySearchResults.value.any { it.pubkeyHex == user.pubkeyHex }) {
+ _relaySearchResults.value = _relaySearchResults.value + user
+ }
+ }
+}
diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt
index a2cffbd11..f7df8af80 100644
--- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt
+++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt
@@ -32,6 +32,7 @@ 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
@@ -68,6 +69,9 @@ class AccountManager private constructor(
private val _accountState = MutableStateFlow(AccountState.LoggedOut)
val accountState: StateFlow = _accountState.asStateFlow()
+ private val _nwcConnection = MutableStateFlow(null)
+ val nwcConnection: StateFlow = _nwcConnection.asStateFlow()
+
/**
* Loads the last saved account from secure storage.
* Call on app startup.
@@ -205,6 +209,47 @@ class AccountManager private constructor(
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 =
+ 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()
diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Bookmarks/BookmarkAction.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Bookmarks/BookmarkAction.kt
new file mode 100644
index 000000000..52c59420b
--- /dev/null
+++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Bookmarks/BookmarkAction.kt
@@ -0,0 +1,149 @@
+/**
+ * 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.model.nip51Bookmarks
+
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
+import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
+import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
+import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
+
+/**
+ * Handles NIP-51 bookmark operations.
+ * Shared between Android and Desktop.
+ */
+object BookmarkAction {
+ /**
+ * Creates a new bookmark list with a single event bookmarked.
+ */
+ suspend fun createWithBookmark(
+ eventId: HexKey,
+ relayHint: NormalizedRelayUrl? = null,
+ isPrivate: Boolean = false,
+ signer: NostrSigner,
+ ): BookmarkListEvent {
+ val bookmark = EventBookmark(eventId, relayHint)
+ return BookmarkListEvent.create(
+ bookmarkIdTag = bookmark,
+ isPrivate = isPrivate,
+ signer = signer,
+ )
+ }
+
+ /**
+ * Adds an event to an existing bookmark list.
+ */
+ suspend fun addBookmark(
+ existingList: BookmarkListEvent,
+ eventId: HexKey,
+ relayHint: NormalizedRelayUrl? = null,
+ isPrivate: Boolean = false,
+ signer: NostrSigner,
+ ): BookmarkListEvent {
+ val bookmark = EventBookmark(eventId, relayHint)
+ return BookmarkListEvent.add(
+ earlierVersion = existingList,
+ bookmarkIdTag = bookmark,
+ isPrivate = isPrivate,
+ signer = signer,
+ )
+ }
+
+ /**
+ * Removes an event from a bookmark list.
+ * Checks both public and private bookmarks.
+ */
+ suspend fun removeBookmark(
+ existingList: BookmarkListEvent,
+ eventId: HexKey,
+ signer: NostrSigner,
+ ): BookmarkListEvent {
+ val bookmark = EventBookmark(eventId)
+ return BookmarkListEvent.remove(
+ earlierVersion = existingList,
+ bookmarkIdTag = bookmark,
+ signer = signer,
+ )
+ }
+
+ /**
+ * Removes an event from a bookmark list (public or private specifically).
+ */
+ suspend fun removeBookmark(
+ existingList: BookmarkListEvent,
+ eventId: HexKey,
+ isPrivate: Boolean,
+ signer: NostrSigner,
+ ): BookmarkListEvent {
+ val bookmark = EventBookmark(eventId)
+ return BookmarkListEvent.remove(
+ earlierVersion = existingList,
+ bookmarkIdTag = bookmark,
+ isPrivate = isPrivate,
+ signer = signer,
+ )
+ }
+
+ /**
+ * Checks if an event ID is in the public bookmarks.
+ */
+ fun isInPublicBookmarks(
+ bookmarkList: BookmarkListEvent?,
+ eventId: HexKey,
+ ): Boolean {
+ if (bookmarkList == null) return false
+ return bookmarkList.publicBookmarks().any {
+ it is EventBookmark && it.eventId == eventId
+ }
+ }
+
+ /**
+ * Checks if an event ID is in the private bookmarks.
+ * Requires decryption via signer.
+ */
+ suspend fun isInPrivateBookmarks(
+ bookmarkList: BookmarkListEvent?,
+ eventId: HexKey,
+ signer: NostrSigner,
+ ): Boolean {
+ if (bookmarkList == null) return false
+ val privateBookmarks = bookmarkList.privateBookmarks(signer) ?: return false
+ return privateBookmarks.any {
+ it is EventBookmark && it.eventId == eventId
+ }
+ }
+
+ /**
+ * Checks if an event ID is bookmarked (public or private).
+ */
+ suspend fun isBookmarked(
+ bookmarkList: BookmarkListEvent?,
+ eventId: HexKey,
+ signer: NostrSigner,
+ ): Boolean =
+ isInPublicBookmarks(bookmarkList, eventId) ||
+ isInPrivateBookmarks(bookmarkList, eventId, signer)
+
+ /**
+ * Gets the bookmark list address for a user.
+ */
+ fun getBookmarkListAddress(pubKey: HexKey) = BookmarkListEvent.createBookmarkAddress(pubKey)
+}
diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip57Zaps/ZapAction.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip57Zaps/ZapAction.kt
new file mode 100644
index 000000000..3fb510267
--- /dev/null
+++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip57Zaps/ZapAction.kt
@@ -0,0 +1,163 @@
+/**
+ * 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.model.nip57Zaps
+
+import com.vitorpamplona.amethyst.commons.services.lnurl.LightningAddressResolver
+import com.vitorpamplona.quartz.nip01Core.core.Event
+import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
+import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
+import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
+import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
+
+/**
+ * Handles NIP-57 zap requests and invoice fetching.
+ * Shared between Android and Desktop.
+ */
+object ZapAction {
+ /**
+ * Result of a zap operation.
+ */
+ sealed class ZapResult {
+ data class Invoice(
+ val bolt11: String,
+ ) : ZapResult()
+
+ data class Error(
+ val message: String,
+ ) : ZapResult()
+ }
+
+ /**
+ * Creates a zap request and fetches a BOLT11 invoice.
+ *
+ * @param targetEvent Event to zap
+ * @param lnAddress Lightning address of recipient
+ * @param amountSats Amount in satoshis
+ * @param message Optional zap message
+ * @param relays Relay hints (normalized URLs)
+ * @param signer Signer for the request
+ * @param resolver Lightning address resolver
+ * @param zapType Type of zap (default PUBLIC)
+ * @param onProgress Progress callback
+ */
+ suspend fun fetchZapInvoice(
+ targetEvent: Event,
+ lnAddress: String,
+ amountSats: Long,
+ message: String = "",
+ relays: Set,
+ signer: NostrSigner,
+ resolver: LightningAddressResolver,
+ zapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC,
+ onProgress: (Float) -> Unit = {},
+ ): ZapResult {
+ if (!signer.isWriteable()) {
+ return ZapResult.Error("Signer is not writeable")
+ }
+
+ // Create zap request using quartz factory
+ val zapRequest =
+ try {
+ LnZapRequestEvent.create(
+ zappedEvent = targetEvent,
+ relays = relays,
+ signer = signer,
+ pollOption = null,
+ message = message,
+ zapType = zapType,
+ toUserPubHex = null,
+ )
+ } catch (e: Exception) {
+ return ZapResult.Error("Failed to create zap request: ${e.message}")
+ }
+
+ onProgress(0.3f)
+
+ // Fetch invoice
+ val result =
+ resolver.fetchInvoice(
+ lnAddress = lnAddress,
+ milliSats = amountSats * 1000,
+ message = message,
+ zapRequest = zapRequest,
+ onProgress = { progress ->
+ onProgress(0.3f + progress * 0.7f)
+ },
+ )
+
+ return when (result) {
+ is LightningAddressResolver.Result.Success -> ZapResult.Invoice(result.invoice)
+ is LightningAddressResolver.Result.Error -> ZapResult.Error(result.message)
+ }
+ }
+
+ /**
+ * Creates a zap request for a user profile (no event).
+ */
+ suspend fun fetchZapInvoiceForUser(
+ userPubHex: String,
+ lnAddress: String,
+ amountSats: Long,
+ message: String = "",
+ relays: Set,
+ signer: NostrSigner,
+ resolver: LightningAddressResolver,
+ zapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC,
+ onProgress: (Float) -> Unit = {},
+ ): ZapResult {
+ if (!signer.isWriteable()) {
+ return ZapResult.Error("Signer is not writeable")
+ }
+
+ // Create zap request for user
+ val zapRequest =
+ try {
+ LnZapRequestEvent.create(
+ userHex = userPubHex,
+ relays = relays,
+ signer = signer,
+ message = message,
+ zapType = zapType,
+ )
+ } catch (e: Exception) {
+ return ZapResult.Error("Failed to create zap request: ${e.message}")
+ }
+
+ onProgress(0.3f)
+
+ // Fetch invoice
+ val result =
+ resolver.fetchInvoice(
+ lnAddress = lnAddress,
+ milliSats = amountSats * 1000,
+ message = message,
+ zapRequest = zapRequest,
+ onProgress = { progress ->
+ onProgress(0.3f + progress * 0.7f)
+ },
+ )
+
+ return when (result) {
+ is LightningAddressResolver.Result.Success -> ZapResult.Invoice(result.invoice)
+ is LightningAddressResolver.Result.Error -> ZapResult.Error(result.message)
+ }
+ }
+}
diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt
index 3e0f7159d..8669b66e8 100644
--- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt
+++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt
@@ -107,6 +107,61 @@ open class RelayConnectionManager(
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,
+ 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?,
+ ) {
+ 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,
diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/lnurl/LightningAddressResolver.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/lnurl/LightningAddressResolver.kt
new file mode 100644
index 000000000..98a60a57d
--- /dev/null
+++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/lnurl/LightningAddressResolver.kt
@@ -0,0 +1,224 @@
+/**
+ * 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.services.lnurl
+
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
+import com.vitorpamplona.quartz.lightning.Lud06
+import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import java.math.BigDecimal
+import java.math.RoundingMode
+import java.net.URLEncoder
+import kotlin.coroutines.cancellation.CancellationException
+
+/**
+ * Platform-agnostic Lightning Address resolver for LNURL-pay flow.
+ * Shared between Android and Desktop for zap functionality.
+ *
+ * Flow:
+ * 1. Lightning address (user@domain) → LNURL endpoint URL
+ * 2. Fetch LNURL-pay JSON → extract callback URL
+ * 3. Call callback with amount → get BOLT11 invoice
+ */
+class LightningAddressResolver(
+ private val httpClient: OkHttpClient,
+) {
+ private val mapper = jacksonObjectMapper()
+
+ /**
+ * Result of resolving a lightning address to a BOLT11 invoice.
+ */
+ sealed class Result {
+ data class Success(
+ val invoice: String,
+ ) : Result()
+
+ data class Error(
+ val message: String,
+ ) : Result()
+ }
+
+ /**
+ * Converts a lightning address to its LNURL-pay endpoint URL.
+ * Supports: user@domain, LNURL bech32
+ */
+ fun assembleUrl(lnAddress: String): String? {
+ val parts = lnAddress.split("@")
+
+ if (parts.size == 2) {
+ return "https://${parts[1]}/.well-known/lnurlp/${parts[0]}"
+ }
+
+ if (lnAddress.lowercase().startsWith("lnurl")) {
+ return Lud06().toLnUrlp(lnAddress)
+ }
+
+ return null
+ }
+
+ /**
+ * Resolves a lightning address to a BOLT11 invoice.
+ *
+ * @param lnAddress Lightning address (user@domain) or LNURL
+ * @param milliSats Amount in millisatoshis
+ * @param message Optional comment for the payment
+ * @param zapRequest Optional NIP-57 zap request event
+ * @param onProgress Progress callback (0.0 to 1.0)
+ */
+ suspend fun fetchInvoice(
+ lnAddress: String,
+ milliSats: Long,
+ message: String = "",
+ zapRequest: LnZapRequestEvent? = null,
+ onProgress: (Float) -> Unit = {},
+ ): Result =
+ withContext(Dispatchers.IO) {
+ try {
+ // Step 1: Resolve LN address to LNURL endpoint
+ val url =
+ assembleUrl(lnAddress)
+ ?: return@withContext Result.Error("Invalid lightning address: $lnAddress")
+
+ onProgress(0.2f)
+
+ // Step 2: Fetch LNURL-pay JSON
+ val lnurlJson =
+ fetchUrl(url)
+ ?: return@withContext Result.Error("Failed to fetch LNURL endpoint: $url")
+
+ onProgress(0.4f)
+
+ val lnurlp =
+ try {
+ mapper.readTree(lnurlJson)
+ } catch (e: Exception) {
+ return@withContext Result.Error("Failed to parse LNURL response")
+ }
+
+ val callbackUrl =
+ lnurlp?.get("callback")?.asText()?.ifBlank { null }
+ ?: return@withContext Result.Error("No callback URL in LNURL response")
+
+ val allowsNostr = lnurlp.get("allowsNostr")?.asBoolean() ?: false
+
+ onProgress(0.5f)
+
+ // Step 3: Fetch invoice from callback
+ val invoiceJson =
+ fetchInvoiceFromCallback(
+ callbackUrl = callbackUrl,
+ milliSats = milliSats,
+ message = message,
+ zapRequest = if (allowsNostr) zapRequest else null,
+ ) ?: return@withContext Result.Error("Failed to fetch invoice from callback")
+
+ onProgress(0.7f)
+
+ val invoiceResponse =
+ try {
+ mapper.readTree(invoiceJson)
+ } catch (e: Exception) {
+ return@withContext Result.Error("Failed to parse invoice response")
+ }
+
+ val pr = invoiceResponse?.get("pr")?.asText()?.ifBlank { null }
+
+ if (pr == null) {
+ val reason = invoiceResponse?.get("reason")?.asText()?.ifBlank { null }
+ return@withContext Result.Error(reason ?: "No invoice in response")
+ }
+
+ // Step 4: Validate invoice amount
+ val expectedAmountInSats =
+ BigDecimal(milliSats)
+ .divide(BigDecimal(1000), RoundingMode.HALF_UP)
+ .toLong()
+
+ val invoiceAmount = LnInvoiceUtil.getAmountInSats(pr)
+
+ if (invoiceAmount.toLong() != expectedAmountInSats) {
+ return@withContext Result.Error(
+ "Invoice amount mismatch: got ${invoiceAmount.toLong()} sats, expected $expectedAmountInSats sats",
+ )
+ }
+
+ onProgress(1.0f)
+
+ Result.Success(pr)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Result.Error(e.message ?: "Unknown error")
+ }
+ }
+
+ private suspend fun fetchUrl(url: String): String? =
+ withContext(Dispatchers.IO) {
+ try {
+ val request = Request.Builder().url(url).build()
+ httpClient.newCall(request).execute().use { response ->
+ if (response.isSuccessful) {
+ response.body?.string()
+ } else {
+ null
+ }
+ }
+ } catch (e: Exception) {
+ if (e is CancellationException) throw e
+ null
+ }
+ }
+
+ private suspend fun fetchInvoiceFromCallback(
+ callbackUrl: String,
+ milliSats: Long,
+ message: String,
+ zapRequest: LnZapRequestEvent?,
+ ): String? =
+ withContext(Dispatchers.IO) {
+ try {
+ val encodedMessage = URLEncoder.encode(message, "utf-8")
+ val urlBinder = if (callbackUrl.contains("?")) "&" else "?"
+ var url = "$callbackUrl${urlBinder}amount=$milliSats&comment=$encodedMessage"
+
+ if (zapRequest != null) {
+ val encodedRequest = URLEncoder.encode(zapRequest.toJson(), "utf-8")
+ url += "&nostr=$encodedRequest"
+ }
+
+ val request = Request.Builder().url(url).build()
+ httpClient.newCall(request).execute().use { response ->
+ if (response.isSuccessful) {
+ response.body?.string()
+ } else {
+ null
+ }
+ }
+ } catch (e: Exception) {
+ if (e is CancellationException) throw e
+ null
+ }
+ }
+}
diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/nwc/NwcPaymentTracker.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/nwc/NwcPaymentTracker.kt
new file mode 100644
index 000000000..68100f6a7
--- /dev/null
+++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/nwc/NwcPaymentTracker.kt
@@ -0,0 +1,97 @@
+/**
+ * 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.services.nwc
+
+import com.vitorpamplona.amethyst.commons.model.Note
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * Tracks pending NIP-47 (Nostr Wallet Connect) payment requests awaiting responses.
+ *
+ * Shared between Android and Desktop to provide consistent payment tracking behavior.
+ * Platform-specific caches (LocalCache, DesktopLocalCache) delegate to this tracker
+ * for the core request/response matching logic.
+ *
+ * Flow:
+ * 1. When sending payment request: [registerRequest] stores callback
+ * 2. When response arrives: [onResponseReceived] retrieves and removes pending request
+ * 3. Caller invokes callback and links notes via Note.addZapPayment()
+ */
+class NwcPaymentTracker {
+ /**
+ * Data for a pending payment request.
+ *
+ * @property zappedNote The note being zapped, if payment is for a zap
+ * @property onResponse Callback to invoke when wallet responds
+ */
+ data class PendingRequest(
+ val zappedNote: Note?,
+ val onResponse: suspend (LnZapPaymentResponseEvent) -> Unit,
+ )
+
+ private val awaitingRequests = ConcurrentHashMap(10)
+
+ /**
+ * Registers a pending payment request.
+ *
+ * @param requestId Event ID of the LnZapPaymentRequestEvent
+ * @param zappedNote The note being zapped (null if not a zap payment)
+ * @param onResponse Callback invoked when response arrives
+ */
+ fun registerRequest(
+ requestId: HexKey,
+ zappedNote: Note?,
+ onResponse: suspend (LnZapPaymentResponseEvent) -> Unit,
+ ) {
+ awaitingRequests[requestId] = PendingRequest(zappedNote, onResponse)
+ }
+
+ /**
+ * Called when a payment response event is received.
+ * Retrieves and removes the pending request for the given request ID.
+ *
+ * @param requestId The 'e' tag from the response, pointing to original request
+ * @return PendingRequest if found, null otherwise
+ */
+ fun onResponseReceived(requestId: HexKey?): PendingRequest? {
+ if (requestId == null) return null
+ return awaitingRequests.remove(requestId)
+ }
+
+ /**
+ * Checks if there's a pending request for the given ID.
+ */
+ fun hasPendingRequest(requestId: HexKey): Boolean = awaitingRequests.containsKey(requestId)
+
+ /**
+ * Manually removes a pending request (e.g., on timeout).
+ */
+ fun cleanup(requestId: HexKey) {
+ awaitingRequests.remove(requestId)
+ }
+
+ /**
+ * Returns count of pending requests (for debugging/monitoring).
+ */
+ fun pendingCount(): Int = awaitingRequests.size
+}
diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/FeedSubscription.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/FeedSubscription.kt
index 60852ec49..21f95caa5 100644
--- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/FeedSubscription.kt
+++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/FeedSubscription.kt
@@ -131,3 +131,187 @@ fun createThreadRepliesSubscription(
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,
+ searchQuery: String,
+ limit: Int = 50,
+ onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit,
+ onEose: (NormalizedRelayUrl, List?) -> 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,
+ searchQuery: String,
+ limit: Int = 50,
+ onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit,
+ onEose: (NormalizedRelayUrl, List?) -> 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,
+ eventIds: List,
+ limit: Int = 100,
+ onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit,
+ onEose: (NormalizedRelayUrl, List?) -> 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,
+ eventIds: List,
+ limit: Int = 100,
+ onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit,
+ onEose: (NormalizedRelayUrl, List?) -> 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,
+ eventIds: List,
+ limit: Int = 100,
+ onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit,
+ onEose: (NormalizedRelayUrl, List?) -> 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,
+ eventIds: List,
+ limit: Int = 100,
+ onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit,
+ onEose: (NormalizedRelayUrl, List?) -> 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,
+ limit: Int = 30,
+ onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit,
+ onEose: (NormalizedRelayUrl, List?) -> 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,
+ followedUsers: List,
+ limit: Int = 30,
+ onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit,
+ onEose: (NormalizedRelayUrl, List?) -> 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,
+ )
+}
diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/FilterBuilders.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/FilterBuilders.kt
index 011d910fa..51eaf8d95 100644
--- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/FilterBuilders.kt
+++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/FilterBuilders.kt
@@ -83,6 +83,18 @@ object FilterBuilders {
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): Filter =
+ Filter(
+ kinds = listOf(0), // MetadataEvent.KIND
+ authors = pubKeyHexList,
+ )
+
/**
* Creates a filter for contact list (kind 3) from a specific author.
*
@@ -256,6 +268,155 @@ object FilterBuilders {
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,
+ 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,
+ 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,
+ 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,
+ 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,
+ limit: Int? = null,
+ since: Long? = null,
+ until: Long? = null,
+ ): Filter =
+ Filter(
+ kinds = listOf(30023), // LongTextNoteEvent.KIND
+ authors = authors,
+ limit = limit,
+ since = since,
+ until = until,
+ )
}
/**
diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/ProfileSubscription.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/ProfileSubscription.kt
index 2355c0fac..dbed8ef4d 100644
--- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/ProfileSubscription.kt
+++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/ProfileSubscription.kt
@@ -26,20 +26,50 @@ 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,
pubKeyHex: String,
onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit,
onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> },
-): SubscriptionConfig =
- SubscriptionConfig(
+): 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,
+ pubKeyHexList: List,
+ onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit,
+ onEose: (NormalizedRelayUrl, List?) -> 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).
diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/EventExtensions.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/EventExtensions.kt
index 0c392b8f8..aa45c97fe 100644
--- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/EventExtensions.kt
+++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/EventExtensions.kt
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.commons.util
+import com.vitorpamplona.amethyst.commons.model.User
+import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.ui.note.NoteDisplayData
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
@@ -28,7 +30,7 @@ import com.vitorpamplona.quartz.nip19Bech32.toNpub
/**
* Extension to convert Event to NoteDisplayData for the shared NoteCard.
*/
-fun Event.toNoteDisplayData(): NoteDisplayData {
+fun Event.toNoteDisplayData(cache: ICacheProvider? = null): NoteDisplayData {
val npub =
try {
pubKey.hexToByteArrayOrNull()?.toNpub() ?: pubKey.take(16) + "..."
@@ -36,10 +38,13 @@ fun Event.toNoteDisplayData(): NoteDisplayData {
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,
)
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt
new file mode 100644
index 000000000..6e11bf154
--- /dev/null
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt
@@ -0,0 +1,57 @@
+/**
+ * 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
+
+import com.vitorpamplona.amethyst.commons.subscriptions.FeedMode
+import java.util.prefs.Preferences
+
+/**
+ * Simple preferences storage using Java's Preferences API.
+ * Data is stored in platform-appropriate location:
+ * - macOS: ~/Library/Preferences/com.apple.java.util.prefs.plist
+ * - Linux: ~/.java/.userPrefs/
+ * - Windows: Registry under HKEY_CURRENT_USER\Software\JavaSoft\Prefs
+ */
+object DesktopPreferences {
+ private val prefs: Preferences = Preferences.userNodeForPackage(DesktopPreferences::class.java)
+
+ private const val KEY_FEED_MODE = "feed_mode"
+ private const val KEY_LAST_SCREEN = "last_screen"
+
+ var feedMode: FeedMode
+ get() {
+ val name = prefs.get(KEY_FEED_MODE, FeedMode.GLOBAL.name)
+ return try {
+ FeedMode.valueOf(name)
+ } catch (e: Exception) {
+ FeedMode.GLOBAL
+ }
+ }
+ set(value) {
+ prefs.put(KEY_FEED_MODE, value.name)
+ }
+
+ var lastScreen: String
+ get() = prefs.get(KEY_LAST_SCREEN, "Feed")
+ set(value) {
+ prefs.put(KEY_LAST_SCREEN, value)
+ }
+}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt
index 86b01d92d..e5d06bd4d 100644
--- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt
@@ -34,6 +34,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Article
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Notifications
@@ -42,6 +43,7 @@ import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -50,12 +52,15 @@ import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -78,14 +83,19 @@ 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.commons.ui.screens.SearchPlaceholder
+import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
+import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen
import com.vitorpamplona.amethyst.desktop.ui.ComposeNoteDialog
import com.vitorpamplona.amethyst.desktop.ui.FeedScreen
import com.vitorpamplona.amethyst.desktop.ui.LoginScreen
import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen
+import com.vitorpamplona.amethyst.desktop.ui.ReadsScreen
+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.quartz.nip47WalletConnect.Nip47WalletConnect
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -99,8 +109,12 @@ private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
sealed class DesktopScreen {
object Feed : DesktopScreen()
+ object Reads : DesktopScreen()
+
object Search : DesktopScreen()
+ object Bookmarks : DesktopScreen()
+
object Messages : DesktopScreen()
object Notifications : DesktopScreen()
@@ -127,6 +141,8 @@ fun main() =
position = WindowPosition.Aligned(Alignment.Center),
)
var showComposeDialog by remember { mutableStateOf(false) }
+ var replyToNote by remember { mutableStateOf(null) }
+ var currentScreen by remember { mutableStateOf(DesktopScreen.Feed) }
Window(
onCloseRequest = ::exitApplication,
@@ -154,7 +170,7 @@ fun main() =
} else {
KeyShortcut(Key.Comma, ctrl = true)
},
- onClick = { /* TODO: Open settings */ },
+ onClick = { currentScreen = DesktopScreen.Settings },
)
Separator()
Item(
@@ -202,21 +218,35 @@ fun main() =
}
App(
+ currentScreen = currentScreen,
+ onScreenChange = { currentScreen = it },
showComposeDialog = showComposeDialog,
onShowComposeDialog = { showComposeDialog = true },
- onDismissComposeDialog = { showComposeDialog = false },
+ onShowReplyDialog = { event ->
+ replyToNote = event
+ showComposeDialog = true
+ },
+ onDismissComposeDialog = {
+ showComposeDialog = false
+ replyToNote = null
+ },
+ replyToNote = replyToNote,
)
}
}
@Composable
fun App(
+ currentScreen: DesktopScreen,
+ onScreenChange: (DesktopScreen) -> Unit,
showComposeDialog: Boolean,
onShowComposeDialog: () -> Unit,
+ onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
onDismissComposeDialog: () -> Unit,
+ replyToNote: com.vitorpamplona.quartz.nip01Core.core.Event?,
) {
- var currentScreen by remember { mutableStateOf(DesktopScreen.Feed) }
val relayManager = remember { DesktopRelayConnectionManager() }
+ val localCache = remember { DesktopLocalCache() }
val accountManager = remember { AccountManager.create() }
val accountState by accountManager.accountState.collectAsState()
val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) }
@@ -246,19 +276,28 @@ fun App(
is AccountState.LoggedOut -> {
LoginScreen(
accountManager = accountManager,
- onLoginSuccess = { currentScreen = DesktopScreen.Feed },
+ onLoginSuccess = { onScreenChange(DesktopScreen.Feed) },
)
}
is AccountState.LoggedIn -> {
val account = accountState as AccountState.LoggedIn
+ val nwcConnection by accountManager.nwcConnection.collectAsState()
+
+ // Load NWC connection on first composition
+ LaunchedEffect(Unit) {
+ accountManager.loadNwcConnection()
+ }
MainContent(
currentScreen = currentScreen,
- onScreenChange = { currentScreen = it },
+ onScreenChange = onScreenChange,
relayManager = relayManager,
+ localCache = localCache,
accountManager = accountManager,
account = account,
+ nwcConnection = nwcConnection,
onShowComposeDialog = onShowComposeDialog,
+ onShowReplyDialog = onShowReplyDialog,
)
// Compose dialog
@@ -267,6 +306,7 @@ fun App(
onDismiss = onDismissComposeDialog,
relayManager = relayManager,
account = account,
+ replyTo = replyToNote,
)
}
}
@@ -280,127 +320,218 @@ fun MainContent(
currentScreen: DesktopScreen,
onScreenChange: (DesktopScreen) -> Unit,
relayManager: DesktopRelayConnectionManager,
+ localCache: DesktopLocalCache,
accountManager: AccountManager,
account: AccountState.LoggedIn,
+ nwcConnection: Nip47WalletConnect.Nip47URINorm?,
onShowComposeDialog: () -> Unit,
+ onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
) {
- Row(Modifier.fillMaxSize()) {
- // Sidebar Navigation
- NavigationRail(
- modifier = Modifier.width(80.dp).fillMaxHeight(),
- containerColor = MaterialTheme.colorScheme.surfaceVariant,
- ) {
- Spacer(Modifier.height(16.dp))
+ val snackbarHostState = remember { SnackbarHostState() }
+ val scope = rememberCoroutineScope()
- NavigationRailItem(
- icon = { Icon(Icons.Default.Home, contentDescription = "Feed") },
- label = { Text("Feed") },
- selected = currentScreen == DesktopScreen.Feed,
- onClick = { onScreenChange(DesktopScreen.Feed) },
- )
-
- NavigationRailItem(
- icon = { Icon(Icons.Default.Search, contentDescription = "Search") },
- label = { Text("Search") },
- selected = currentScreen == DesktopScreen.Search,
- onClick = { onScreenChange(DesktopScreen.Search) },
- )
-
- NavigationRailItem(
- icon = { Icon(Icons.Default.Email, contentDescription = "Messages") },
- label = { Text("DMs") },
- selected = currentScreen == DesktopScreen.Messages,
- onClick = { onScreenChange(DesktopScreen.Messages) },
- )
-
- NavigationRailItem(
- icon = { Icon(Icons.Default.Notifications, contentDescription = "Notifications") },
- label = { Text("Alerts") },
- selected = currentScreen == DesktopScreen.Notifications,
- onClick = { onScreenChange(DesktopScreen.Notifications) },
- )
-
- NavigationRailItem(
- icon = { Icon(Icons.Default.Person, contentDescription = "Profile") },
- label = { Text("Profile") },
- selected = currentScreen == DesktopScreen.MyProfile || currentScreen is DesktopScreen.UserProfile,
- onClick = { onScreenChange(DesktopScreen.MyProfile) },
- )
-
- Spacer(Modifier.weight(1f))
-
- HorizontalDivider(Modifier.padding(horizontal = 16.dp))
-
- NavigationRailItem(
- icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
- label = { Text("Settings") },
- selected = currentScreen == DesktopScreen.Settings,
- onClick = { onScreenChange(DesktopScreen.Settings) },
- )
-
- Spacer(Modifier.height(16.dp))
+ val onZapFeedback: (ZapFeedback) -> Unit = { feedback ->
+ scope.launch {
+ val message =
+ when (feedback) {
+ is ZapFeedback.Success -> "Zapped ${feedback.amountSats} sats"
+ is ZapFeedback.ExternalWallet -> "Invoice sent to wallet (${feedback.amountSats} sats)"
+ is ZapFeedback.Error -> "Zap failed: ${feedback.message}"
+ is ZapFeedback.Timeout -> "Zap timed out"
+ is ZapFeedback.NoLightningAddress -> "User has no lightning address"
+ }
+ snackbarHostState.showSnackbar(message)
}
+ }
- VerticalDivider()
+ Box(Modifier.fillMaxSize()) {
+ Row(Modifier.fillMaxSize()) {
+ // Sidebar Navigation
+ NavigationRail(
+ modifier = Modifier.width(80.dp).fillMaxHeight(),
+ containerColor = MaterialTheme.colorScheme.surfaceVariant,
+ ) {
+ Spacer(Modifier.height(16.dp))
- // Main Content
- Box(
- modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp),
- ) {
- when (currentScreen) {
- DesktopScreen.Feed ->
- FeedScreen(
- relayManager = relayManager,
- account = account,
- onCompose = onShowComposeDialog,
- onNavigateToProfile = { pubKeyHex ->
- onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
- },
- onNavigateToThread = { noteId ->
- onScreenChange(DesktopScreen.Thread(noteId))
- },
- )
- DesktopScreen.Search -> SearchPlaceholder()
- DesktopScreen.Messages -> MessagesPlaceholder()
- DesktopScreen.Notifications -> NotificationsScreen(relayManager, account)
- DesktopScreen.MyProfile ->
- UserProfileScreen(
- pubKeyHex = account.pubKeyHex,
- relayManager = relayManager,
- account = account,
- onBack = { onScreenChange(DesktopScreen.Feed) },
- onCompose = onShowComposeDialog,
- onNavigateToProfile = { pubKeyHex ->
- onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
- },
- )
- is DesktopScreen.UserProfile ->
- UserProfileScreen(
- pubKeyHex = currentScreen.pubKeyHex,
- relayManager = relayManager,
- account = account,
- onBack = { onScreenChange(DesktopScreen.Feed) },
- onCompose = onShowComposeDialog,
- onNavigateToProfile = { pubKeyHex ->
- onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
- },
- )
- is DesktopScreen.Thread ->
- ThreadScreen(
- noteId = currentScreen.noteId,
- relayManager = relayManager,
- account = account,
- onBack = { onScreenChange(DesktopScreen.Feed) },
- onNavigateToProfile = { pubKeyHex ->
- onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
- },
- onNavigateToThread = { noteId ->
- onScreenChange(DesktopScreen.Thread(noteId))
- },
- )
- DesktopScreen.Settings -> RelaySettingsScreen(relayManager, account)
+ NavigationRailItem(
+ icon = { Icon(Icons.Default.Home, contentDescription = "Feed") },
+ label = { Text("Feed") },
+ selected = currentScreen == DesktopScreen.Feed,
+ onClick = { onScreenChange(DesktopScreen.Feed) },
+ )
+
+ NavigationRailItem(
+ icon = { Icon(Icons.AutoMirrored.Filled.Article, contentDescription = "Reads") },
+ label = { Text("Reads") },
+ selected = currentScreen == DesktopScreen.Reads,
+ onClick = { onScreenChange(DesktopScreen.Reads) },
+ )
+
+ NavigationRailItem(
+ icon = { Icon(Icons.Default.Search, contentDescription = "Search") },
+ label = { Text("Search") },
+ selected = currentScreen == DesktopScreen.Search,
+ onClick = { onScreenChange(DesktopScreen.Search) },
+ )
+
+ NavigationRailItem(
+ icon = { Icon(com.vitorpamplona.amethyst.commons.icons.Bookmark, contentDescription = "Bookmarks") },
+ label = { Text("Bookmarks") },
+ selected = currentScreen == DesktopScreen.Bookmarks,
+ onClick = { onScreenChange(DesktopScreen.Bookmarks) },
+ )
+
+ NavigationRailItem(
+ icon = { Icon(Icons.Default.Email, contentDescription = "Messages") },
+ label = { Text("DMs") },
+ selected = currentScreen == DesktopScreen.Messages,
+ onClick = { onScreenChange(DesktopScreen.Messages) },
+ )
+
+ NavigationRailItem(
+ icon = { Icon(Icons.Default.Notifications, contentDescription = "Notifications") },
+ label = { Text("Alerts") },
+ selected = currentScreen == DesktopScreen.Notifications,
+ onClick = { onScreenChange(DesktopScreen.Notifications) },
+ )
+
+ NavigationRailItem(
+ icon = { Icon(Icons.Default.Person, contentDescription = "Profile") },
+ label = { Text("Profile") },
+ selected = currentScreen == DesktopScreen.MyProfile || currentScreen is DesktopScreen.UserProfile,
+ onClick = { onScreenChange(DesktopScreen.MyProfile) },
+ )
+
+ Spacer(Modifier.weight(1f))
+
+ HorizontalDivider(Modifier.padding(horizontal = 16.dp))
+
+ NavigationRailItem(
+ icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
+ label = { Text("Settings") },
+ selected = currentScreen == DesktopScreen.Settings,
+ onClick = { onScreenChange(DesktopScreen.Settings) },
+ )
+
+ Spacer(Modifier.height(16.dp))
+ }
+
+ VerticalDivider()
+
+ // Main Content
+ Box(
+ modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp),
+ ) {
+ when (currentScreen) {
+ DesktopScreen.Feed ->
+ FeedScreen(
+ relayManager = relayManager,
+ localCache = localCache,
+ account = account,
+ nwcConnection = nwcConnection,
+ onCompose = onShowComposeDialog,
+ onNavigateToProfile = { pubKeyHex ->
+ onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
+ },
+ onNavigateToThread = { noteId ->
+ onScreenChange(DesktopScreen.Thread(noteId))
+ },
+ onZapFeedback = onZapFeedback,
+ )
+ DesktopScreen.Reads ->
+ ReadsScreen(
+ relayManager = relayManager,
+ localCache = localCache,
+ account = account,
+ onNavigateToProfile = { pubKeyHex ->
+ onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
+ },
+ onNavigateToArticle = { noteId ->
+ onScreenChange(DesktopScreen.Thread(noteId))
+ },
+ )
+ DesktopScreen.Search ->
+ SearchScreen(
+ localCache = localCache,
+ relayManager = relayManager,
+ onNavigateToProfile = { pubKeyHex ->
+ onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
+ },
+ onNavigateToThread = { noteId ->
+ onScreenChange(DesktopScreen.Thread(noteId))
+ },
+ )
+ DesktopScreen.Bookmarks ->
+ BookmarksScreen(
+ relayManager = relayManager,
+ localCache = localCache,
+ account = account,
+ nwcConnection = nwcConnection,
+ onNavigateToProfile = { pubKeyHex ->
+ onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
+ },
+ onNavigateToThread = { noteId ->
+ onScreenChange(DesktopScreen.Thread(noteId))
+ },
+ onZapFeedback = onZapFeedback,
+ )
+ DesktopScreen.Messages -> MessagesPlaceholder()
+ DesktopScreen.Notifications -> NotificationsScreen(relayManager, account)
+ DesktopScreen.MyProfile ->
+ UserProfileScreen(
+ pubKeyHex = account.pubKeyHex,
+ relayManager = relayManager,
+ localCache = localCache,
+ account = account,
+ nwcConnection = nwcConnection,
+ onBack = { onScreenChange(DesktopScreen.Feed) },
+ onCompose = onShowComposeDialog,
+ onNavigateToProfile = { pubKeyHex ->
+ onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
+ },
+ onZapFeedback = onZapFeedback,
+ )
+ is DesktopScreen.UserProfile ->
+ UserProfileScreen(
+ pubKeyHex = currentScreen.pubKeyHex,
+ relayManager = relayManager,
+ localCache = localCache,
+ account = account,
+ nwcConnection = nwcConnection,
+ onBack = { onScreenChange(DesktopScreen.Feed) },
+ onCompose = onShowComposeDialog,
+ onNavigateToProfile = { pubKeyHex ->
+ onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
+ },
+ onZapFeedback = onZapFeedback,
+ )
+ is DesktopScreen.Thread ->
+ ThreadScreen(
+ noteId = currentScreen.noteId,
+ relayManager = relayManager,
+ localCache = localCache,
+ account = account,
+ nwcConnection = nwcConnection,
+ onBack = { onScreenChange(DesktopScreen.Feed) },
+ onNavigateToProfile = { pubKeyHex ->
+ onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
+ },
+ onNavigateToThread = { noteId ->
+ onScreenChange(DesktopScreen.Thread(noteId))
+ },
+ onZapFeedback = onZapFeedback,
+ onReply = onShowReplyDialog,
+ )
+ DesktopScreen.Settings -> RelaySettingsScreen(relayManager, account, accountManager)
+ }
}
}
+
+ // Snackbar for zap feedback
+ SnackbarHost(
+ hostState = snackbarHostState,
+ modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
+ )
}
}
@@ -443,10 +574,19 @@ fun ProfileScreen(
fun RelaySettingsScreen(
relayManager: DesktopRelayConnectionManager,
account: AccountState.LoggedIn,
+ accountManager: AccountManager,
) {
val relayStatuses by relayManager.relayStatuses.collectAsState()
val connectedRelays by relayManager.connectedRelays.collectAsState()
+ val nwcConnection by accountManager.nwcConnection.collectAsState()
var newRelayUrl by remember { mutableStateOf("") }
+ var nwcInput by remember { mutableStateOf("") }
+ var nwcError by remember { mutableStateOf(null) }
+
+ // Load NWC on first composition
+ LaunchedEffect(Unit) {
+ accountManager.loadNwcConnection()
+ }
Column(modifier = Modifier.fillMaxSize()) {
Text(
@@ -457,6 +597,88 @@ fun RelaySettingsScreen(
Spacer(Modifier.height(24.dp))
+ // Wallet Connect Section
+ Text(
+ "Wallet Connect (NWC)",
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onBackground,
+ )
+ Spacer(Modifier.height(8.dp))
+
+ Text(
+ "Connect a Lightning wallet to enable zaps. Get a connection string from Alby, Mutiny, or other NWC-compatible wallets.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ Spacer(Modifier.height(12.dp))
+
+ if (nwcConnection != null) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column {
+ Text(
+ "Wallet Connected",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ Text(
+ "Relay: ${nwcConnection!!.relayUri.url}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ OutlinedButton(
+ onClick = { accountManager.clearNwcConnection() },
+ colors =
+ ButtonDefaults.outlinedButtonColors(
+ contentColor = MaterialTheme.colorScheme.error,
+ ),
+ ) {
+ Text("Disconnect")
+ }
+ }
+ } else {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ OutlinedTextField(
+ value = nwcInput,
+ onValueChange = {
+ nwcInput = it
+ nwcError = null
+ },
+ label = { Text("NWC Connection String") },
+ placeholder = { Text("nostr+walletconnect://...") },
+ modifier = Modifier.weight(1f),
+ singleLine = true,
+ isError = nwcError != null,
+ supportingText = nwcError?.let { { Text(it, color = MaterialTheme.colorScheme.error) } },
+ )
+ Button(
+ onClick = {
+ val result = accountManager.setNwcConnection(nwcInput)
+ result.fold(
+ onSuccess = { nwcInput = "" },
+ onFailure = { nwcError = it.message ?: "Invalid connection string" },
+ )
+ },
+ enabled = nwcInput.isNotBlank(),
+ ) {
+ Text("Connect")
+ }
+ }
+ }
+
+ Spacer(Modifier.height(24.dp))
+ HorizontalDivider()
+ Spacer(Modifier.height(24.dp))
+
// Developer Settings Section (only in debug mode)
if (DebugConfig.isDebugMode) {
com.vitorpamplona.amethyst.desktop.ui
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt
new file mode 100644
index 000000000..7b0144e93
--- /dev/null
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt
@@ -0,0 +1,262 @@
+/**
+ * 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.cache
+
+import com.vitorpamplona.amethyst.commons.model.Note
+import com.vitorpamplona.amethyst.commons.model.User
+import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream
+import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
+import com.vitorpamplona.amethyst.commons.model.cache.IChannel
+import com.vitorpamplona.amethyst.commons.services.nwc.NwcPaymentTracker
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
+import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
+import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
+import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
+import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.launch
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * Desktop implementation of ICacheProvider.
+ *
+ * Provides in-memory caching of Users and Notes for the desktop application.
+ * Supports searching users by name prefix for the search functionality.
+ */
+class DesktopLocalCache : ICacheProvider {
+ private val users = ConcurrentHashMap()
+ private val notes = ConcurrentHashMap()
+ private val deletedEvents = ConcurrentHashMap.newKeySet()
+
+ private val eventStream = DesktopCacheEventStream()
+
+ val paymentTracker = NwcPaymentTracker()
+
+ // ----- User operations -----
+
+ override fun getUserIfExists(pubkey: HexKey): User? = users[pubkey]
+
+ override fun getOrCreateUser(pubkey: HexKey): User =
+ users.getOrPut(pubkey) {
+ // Create placeholder notes for relay lists
+ val nip65Note = getOrCreateNote("nip65:$pubkey")
+ val dmNote = getOrCreateNote("dm:$pubkey")
+ User(pubkey, nip65Note, dmNote, this)
+ }
+
+ override fun countUsers(predicate: (String, Any) -> Boolean): Int = users.count { (key, user) -> predicate(key, user) }
+
+ override fun findUsersStartingWith(
+ prefix: String,
+ limit: Int,
+ ): List {
+ if (prefix.isBlank()) return emptyList()
+
+ // Check if it's a valid pubkey/npub first
+ val pubkeyHex = decodePublicKeyAsHexOrNull(prefix)
+ if (pubkeyHex != null) {
+ val user = getUserIfExists(pubkeyHex)
+ if (user != null) return listOf(user)
+ }
+
+ // Search by name/displayName/nip05/lud16
+ return users.values
+ .filter { user ->
+ user.anyNameStartsWith(prefix) ||
+ user.pubkeyHex.startsWith(prefix, ignoreCase = true) ||
+ user.pubkeyNpub().startsWith(prefix, ignoreCase = true)
+ }.sortedWith(
+ compareBy(
+ { !it.toBestDisplayName().startsWith(prefix, ignoreCase = true) },
+ { it.toBestDisplayName().lowercase() },
+ { it.pubkeyHex },
+ ),
+ ).take(limit)
+ }
+
+ /**
+ * Updates user metadata from a MetadataEvent.
+ * Called when receiving kind 0 events from relays.
+ */
+ fun consumeMetadata(event: MetadataEvent) {
+ val user = getOrCreateUser(event.pubKey)
+
+ // Only update if newer
+ val currentMetadata = user.latestMetadata
+ if (currentMetadata == null || event.createdAt > currentMetadata.createdAt) {
+ user.latestMetadata = event
+ user.info = event.contactMetaData()
+ }
+ }
+
+ // ----- NWC Payment operations -----
+
+ /**
+ * Consumes a NIP-47 payment request event.
+ * Registers the request with the tracker and links it to the zapped note.
+ *
+ * @param event The payment request event
+ * @param zappedNote The note being zapped (if this payment is for a zap)
+ * @param relay The relay this event came from
+ * @param onResponse Callback invoked when wallet responds
+ * @return true if event was processed, false if already seen
+ */
+ fun consume(
+ event: LnZapPaymentRequestEvent,
+ zappedNote: Note?,
+ relay: NormalizedRelayUrl?,
+ onResponse: suspend (LnZapPaymentResponseEvent) -> Unit,
+ ): Boolean {
+ val note = getOrCreateNote(event.id)
+ val author = getOrCreateUser(event.pubKey)
+
+ // Already processed this event
+ if (note.event != null) return false
+
+ note.loadEvent(event, author, emptyList())
+ relay?.let { note.addRelay(it) }
+
+ zappedNote?.addZapPayment(note, null)
+ paymentTracker.registerRequest(event.id, zappedNote, onResponse)
+
+ return true
+ }
+
+ /**
+ * Consumes a NIP-47 payment response event.
+ * Matches to pending request, links notes, and invokes callback.
+ *
+ * @param event The payment response event
+ * @param relay The relay this event came from
+ * @return true if event was processed, false if no matching request
+ */
+ fun consume(
+ event: LnZapPaymentResponseEvent,
+ relay: NormalizedRelayUrl?,
+ ): Boolean {
+ val requestId = event.requestId()
+ val pending = paymentTracker.onResponseReceived(requestId) ?: return false
+
+ val requestNote = requestId?.let { getNoteIfExists(it) }
+ val note = getOrCreateNote(event.id)
+ val author = getOrCreateUser(event.pubKey)
+
+ // Already processed this event
+ if (note.event != null) return false
+
+ note.loadEvent(event, author, emptyList())
+ relay?.let { note.addRelay(it) }
+
+ // Link response to zapped note via request
+ requestNote?.let { req -> pending.zappedNote?.addZapPayment(req, note) }
+
+ // Invoke callback on IO dispatcher
+ GlobalScope.launch(Dispatchers.IO) {
+ pending.onResponse(event)
+ }
+
+ return true
+ }
+
+ // ----- Note operations -----
+
+ override fun getNoteIfExists(hexKey: HexKey): Note? = notes[hexKey]
+
+ override fun checkGetOrCreateNote(hexKey: HexKey): Note = getOrCreateNote(hexKey)
+
+ fun getOrCreateNote(hexKey: HexKey): Note =
+ notes.getOrPut(hexKey) {
+ Note(hexKey, this)
+ }
+
+ // ----- Channel operations -----
+
+ override fun getAnyChannel(note: Any?): IChannel? {
+ // Desktop doesn't support channels yet
+ return null
+ }
+
+ // ----- Deletion tracking -----
+
+ override fun hasBeenDeleted(event: Any): Boolean =
+ when (event) {
+ is Note -> deletedEvents.contains(event.idHex)
+ is com.vitorpamplona.quartz.nip01Core.core.Event -> deletedEvents.contains(event.id)
+ else -> false
+ }
+
+ fun markAsDeleted(eventId: HexKey) {
+ deletedEvents.add(eventId)
+ }
+
+ // ----- Event stream -----
+
+ override fun getEventStream(): ICacheEventStream = eventStream
+
+ /**
+ * Emits a new note bundle to observers.
+ */
+ suspend fun emitNewNotes(notes: Set) {
+ eventStream.emitNewNotes(notes)
+ }
+
+ /**
+ * Emits deleted notes to observers.
+ */
+ suspend fun emitDeletedNotes(notes: Set) {
+ eventStream.emitDeletedNotes(notes)
+ }
+
+ // ----- Stats -----
+
+ fun userCount(): Int = users.size
+
+ fun noteCount(): Int = notes.size
+
+ fun clear() {
+ users.clear()
+ notes.clear()
+ deletedEvents.clear()
+ }
+}
+
+/**
+ * Desktop implementation of ICacheEventStream.
+ */
+class DesktopCacheEventStream : ICacheEventStream {
+ private val _newEventBundles = MutableSharedFlow>(replay = 0)
+ private val _deletedEventBundles = MutableSharedFlow>(replay = 0)
+
+ override val newEventBundles: SharedFlow> = _newEventBundles
+ override val deletedEventBundles: SharedFlow> = _deletedEventBundles
+
+ suspend fun emitNewNotes(notes: Set) {
+ _newEventBundles.emit(notes)
+ }
+
+ suspend fun emitDeletedNotes(notes: Set) {
+ _deletedEventBundles.emit(notes)
+ }
+}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcPaymentHandler.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcPaymentHandler.kt
new file mode 100644
index 000000000..2f9388e38
--- /dev/null
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcPaymentHandler.kt
@@ -0,0 +1,181 @@
+/**
+ * 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.nwc
+
+import com.vitorpamplona.amethyst.commons.model.Note
+import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
+import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
+import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
+import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
+import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
+import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
+import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
+import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
+import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
+import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse
+import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse
+import com.vitorpamplona.quartz.nip47WalletConnect.Response
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withTimeoutOrNull
+import kotlin.coroutines.resume
+
+/**
+ * Handles NIP-47 (Nostr Wallet Connect) payments for desktop.
+ *
+ * Flow:
+ * 1. Create payment request event with BOLT11 invoice
+ * 2. Register with tracker for persistent tracking in Note.zapPayments
+ * 3. Send to wallet's relay
+ * 4. Subscribe and wait for wallet response
+ *
+ * @param relayManager Manages relay connections for sending/subscribing
+ * @param localCache Cache for persistent payment tracking
+ */
+class NwcPaymentHandler(
+ private val relayManager: DesktopRelayConnectionManager,
+ private val localCache: DesktopLocalCache,
+) {
+ sealed class PaymentResult {
+ data class Success(
+ val preimage: String?,
+ ) : PaymentResult()
+
+ data class Error(
+ val message: String,
+ ) : PaymentResult()
+
+ data object Timeout : PaymentResult()
+ }
+
+ /**
+ * Sends a payment request via NWC and waits for response.
+ * Payment is tracked in Note.zapPayments for the zapped note.
+ *
+ * @param bolt11 The BOLT11 invoice to pay
+ * @param nwcConnection The NWC connection details (pubkey, relay, secret)
+ * @param zappedNote The note being zapped (for tracking in Note.zapPayments)
+ * @param timeoutMs How long to wait for payment response (default 60s)
+ * @return PaymentResult indicating success, error, or timeout
+ */
+ suspend fun payInvoice(
+ bolt11: String,
+ nwcConnection: Nip47WalletConnect.Nip47URINorm,
+ zappedNote: Note? = null,
+ timeoutMs: Long = 60_000,
+ ): PaymentResult {
+ val secret = nwcConnection.secret ?: return PaymentResult.Error("NWC connection has no secret")
+
+ // Create signer from NWC secret
+ val nwcSigner = NostrSignerInternal(KeyPair(secret.hexToByteArray()))
+
+ // Create payment request event
+ val requestEvent =
+ LnZapPaymentRequestEvent.create(
+ lnInvoice = bolt11,
+ walletServicePubkey = nwcConnection.pubKeyHex,
+ signer = nwcSigner,
+ )
+
+ // Register request note in cache for tracking
+ val requestNote = localCache.getOrCreateNote(requestEvent.id)
+ requestNote.loadEvent(requestEvent, localCache.getOrCreateUser(requestEvent.pubKey), emptyList())
+ requestNote.addRelay(nwcConnection.relayUri)
+
+ // Link to zapped note for persistent tracking
+ zappedNote?.addZapPayment(requestNote, null)
+
+ // Send request to wallet's relay
+ relayManager.sendToRelay(nwcConnection.relayUri, requestEvent)
+
+ // Subscribe and wait for response with timeout
+ return withTimeoutOrNull(timeoutMs) {
+ waitForResponse(requestEvent.id, nwcConnection, nwcSigner, zappedNote, requestNote)
+ } ?: PaymentResult.Timeout
+ }
+
+ private suspend fun waitForResponse(
+ requestId: String,
+ nwcConnection: Nip47WalletConnect.Nip47URINorm,
+ nwcSigner: NostrSignerInternal,
+ zappedNote: Note?,
+ requestNote: Note,
+ ): PaymentResult =
+ suspendCancellableCoroutine { continuation ->
+ val filter =
+ Filter(
+ kinds = listOf(LnZapPaymentResponseEvent.KIND),
+ authors = listOf(nwcConnection.pubKeyHex),
+ tags = mapOf("e" to listOf(requestId)),
+ )
+
+ val subId = "nwc-response-${requestId.take(8)}"
+
+ relayManager.subscribeOnRelay(
+ relay = nwcConnection.relayUri,
+ subId = subId,
+ filters = listOf(filter),
+ onEvent = { event, relay ->
+ if (event is LnZapPaymentResponseEvent && event.requestId() == requestId) {
+ // Unsubscribe
+ relayManager.closeSubscription(nwcConnection.relayUri, subId)
+
+ // Store response note and link to zapped note
+ val responseNote = localCache.getOrCreateNote(event.id)
+ responseNote.loadEvent(event, localCache.getOrCreateUser(event.pubKey), emptyList())
+ responseNote.addRelay(relay)
+ zappedNote?.addZapPayment(requestNote, responseNote)
+
+ // Decrypt and process response
+ try {
+ kotlinx.coroutines.runBlocking {
+ val response = event.decrypt(nwcSigner)
+ val result = processResponse(response)
+ if (continuation.isActive) {
+ continuation.resume(result)
+ }
+ }
+ } catch (e: Exception) {
+ if (continuation.isActive) {
+ continuation.resume(PaymentResult.Error("Failed to decrypt response: ${e.message}"))
+ }
+ }
+ }
+ },
+ )
+
+ continuation.invokeOnCancellation {
+ relayManager.closeSubscription(nwcConnection.relayUri, subId)
+ }
+ }
+
+ private fun processResponse(response: Response): PaymentResult =
+ when (response) {
+ is PayInvoiceSuccessResponse -> {
+ PaymentResult.Success(response.result?.preimage)
+ }
+ is PayInvoiceErrorResponse -> {
+ PaymentResult.Error(response.error?.message ?: "Unknown error")
+ }
+ else -> {
+ PaymentResult.Error("Unexpected response type: ${response.resultType}")
+ }
+ }
+}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt
new file mode 100644
index 000000000..fbc40ff75
--- /dev/null
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt
@@ -0,0 +1,336 @@
+/**
+ * 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 androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+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.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.cache.DesktopLocalCache
+import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
+import com.vitorpamplona.quartz.nip01Core.core.Event
+import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
+import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
+import kotlinx.coroutines.launch
+
+private enum class BookmarkTab { PUBLIC, PRIVATE }
+
+/**
+ * Screen displaying user's bookmarked notes (public and private).
+ */
+@Composable
+fun BookmarksScreen(
+ relayManager: DesktopRelayConnectionManager,
+ localCache: DesktopLocalCache,
+ account: AccountState.LoggedIn,
+ nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
+ onNavigateToProfile: (String) -> Unit = {},
+ onNavigateToThread: (String) -> Unit = {},
+ onZapFeedback: (ZapFeedback) -> Unit = {},
+) {
+ val relayStatuses by relayManager.relayStatuses.collectAsState()
+ val scope = rememberCoroutineScope()
+
+ // Tab state
+ var selectedTab by remember { mutableStateOf(BookmarkTab.PUBLIC) }
+
+ // State for bookmark list
+ var bookmarkList by remember { mutableStateOf(null) }
+ var publicBookmarkIds by remember { mutableStateOf>(emptyList()) }
+ var privateBookmarkIds by remember { mutableStateOf>(emptyList()) }
+ var isLoading by remember { mutableStateOf(true) }
+ var hasReceivedEose by remember { mutableStateOf(false) }
+
+ // State for fetched bookmark events
+ val publicEventState =
+ remember(account.pubKeyHex) {
+ EventCollectionState(
+ getId = { it.id },
+ maxSize = 100,
+ scope = scope,
+ )
+ }
+ val publicEvents by publicEventState.items.collectAsState()
+
+ val privateEventState =
+ remember(account.pubKeyHex) {
+ EventCollectionState(
+ getId = { it.id },
+ maxSize = 100,
+ scope = scope,
+ )
+ }
+ val privateEvents by privateEventState.items.collectAsState()
+
+ // Subscribe to user's bookmark list (kind 30001)
+ rememberSubscription(relayStatuses, account.pubKeyHex, relayManager = relayManager) {
+ val configuredRelays = relayStatuses.keys
+ if (configuredRelays.isNotEmpty()) {
+ SubscriptionConfig(
+ subId = "bookmarks-list-${account.pubKeyHex.take(8)}",
+ filters =
+ listOf(
+ FilterBuilders.byAuthors(
+ authors = listOf(account.pubKeyHex),
+ kinds = listOf(BookmarkListEvent.KIND),
+ limit = 1,
+ ),
+ ),
+ relays = configuredRelays,
+ onEvent = { event, _, _, _ ->
+ if (event is BookmarkListEvent) {
+ bookmarkList = event
+ // Extract public bookmarked event IDs
+ val pubIds =
+ event
+ .publicBookmarks()
+ .filterIsInstance()
+ .map { it.eventId }
+ publicBookmarkIds = pubIds
+ }
+ },
+ onEose = { _, _ ->
+ hasReceivedEose = true
+ isLoading = false
+ },
+ )
+ } else {
+ isLoading = false
+ null
+ }
+ }
+
+ // Decrypt private bookmarks when bookmark list changes
+ LaunchedEffect(bookmarkList) {
+ bookmarkList?.let { list ->
+ scope.launch {
+ try {
+ val privateBookmarks = list.privateBookmarks(account.signer)
+ val privIds =
+ privateBookmarks
+ ?.filterIsInstance()
+ ?.map { it.eventId }
+ ?: emptyList()
+ privateBookmarkIds = privIds
+ } catch (e: Exception) {
+ println("Failed to decrypt private bookmarks: ${e.message}")
+ privateBookmarkIds = emptyList()
+ }
+ }
+ }
+ }
+
+ // Subscribe to fetch the actual public bookmarked events
+ rememberSubscription(relayStatuses, publicBookmarkIds, relayManager = relayManager) {
+ val configuredRelays = relayStatuses.keys
+ if (configuredRelays.isNotEmpty() && publicBookmarkIds.isNotEmpty()) {
+ publicEventState.clear()
+ SubscriptionConfig(
+ subId = "public-bookmarked-events-${System.currentTimeMillis()}",
+ filters =
+ listOf(
+ FilterBuilders.byIds(publicBookmarkIds),
+ ),
+ relays = configuredRelays,
+ onEvent = { event, _, _, _ ->
+ publicEventState.addItem(event)
+ },
+ onEose = { _, _ -> },
+ )
+ } else {
+ null
+ }
+ }
+
+ // Subscribe to fetch the actual private bookmarked events
+ rememberSubscription(relayStatuses, privateBookmarkIds, relayManager = relayManager) {
+ val configuredRelays = relayStatuses.keys
+ if (configuredRelays.isNotEmpty() && privateBookmarkIds.isNotEmpty()) {
+ privateEventState.clear()
+ SubscriptionConfig(
+ subId = "private-bookmarked-events-${System.currentTimeMillis()}",
+ filters =
+ listOf(
+ FilterBuilders.byIds(privateBookmarkIds),
+ ),
+ relays = configuredRelays,
+ onEvent = { event, _, _, _ ->
+ privateEventState.addItem(event)
+ },
+ onEose = { _, _ -> },
+ )
+ } else {
+ null
+ }
+ }
+
+ val currentEvents = if (selectedTab == BookmarkTab.PUBLIC) publicEvents else privateEvents
+ val currentBookmarkIds = if (selectedTab == BookmarkTab.PUBLIC) publicBookmarkIds else privateBookmarkIds
+
+ Column(modifier = Modifier.fillMaxSize()) {
+ // Header with tabs
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = "Bookmarks",
+ style = MaterialTheme.typography.headlineMedium,
+ color = MaterialTheme.colorScheme.onBackground,
+ )
+
+ Spacer(Modifier.weight(1f))
+
+ // Tab selector
+ Row(
+ horizontalArrangement =
+ androidx.compose.foundation.layout.Arrangement
+ .spacedBy(8.dp),
+ ) {
+ FilterChip(
+ selected = selectedTab == BookmarkTab.PUBLIC,
+ onClick = { selectedTab = BookmarkTab.PUBLIC },
+ label = { Text("Public (${publicBookmarkIds.size})") },
+ )
+ FilterChip(
+ selected = selectedTab == BookmarkTab.PRIVATE,
+ onClick = { selectedTab = BookmarkTab.PRIVATE },
+ label = { Text("Private (${privateBookmarkIds.size})") },
+ )
+ }
+ }
+
+ // Content
+ when {
+ isLoading && !hasReceivedEose -> {
+ LoadingState(message = "Loading bookmarks...")
+ }
+ currentBookmarkIds.isEmpty() && hasReceivedEose -> {
+ EmptyState(
+ title = if (selectedTab == BookmarkTab.PUBLIC) "No public bookmarks" else "No private bookmarks",
+ description =
+ if (selectedTab == BookmarkTab.PUBLIC) {
+ "Bookmark notes publicly to save them here"
+ } else {
+ "Private bookmarks are encrypted and only visible to you"
+ },
+ )
+ }
+ else -> {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ items(currentEvents, key = { it.id }) { event ->
+ Column(
+ modifier =
+ Modifier.clickable {
+ onNavigateToThread(event.id)
+ },
+ ) {
+ NoteCard(
+ note = event.toNoteDisplayData(localCache),
+ onAuthorClick = onNavigateToProfile,
+ )
+ NoteActionsRow(
+ event = event,
+ relayManager = relayManager,
+ localCache = localCache,
+ account = account,
+ nwcConnection = nwcConnection,
+ onReplyClick = { onNavigateToThread(event.id) },
+ onZapFeedback = onZapFeedback,
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
+ isBookmarked = true,
+ bookmarkList = bookmarkList,
+ onBookmarkChanged = { newList ->
+ bookmarkList = newList
+ // Update public bookmark IDs
+ val pubIds =
+ newList
+ .publicBookmarks()
+ .filterIsInstance()
+ .map { it.eventId }
+ publicBookmarkIds = pubIds
+
+ // Decrypt and update private bookmark IDs
+ scope.launch {
+ try {
+ val privateBookmarks = newList.privateBookmarks(account.signer)
+ val privIds =
+ privateBookmarks
+ ?.filterIsInstance()
+ ?.map { it.eventId }
+ ?: emptyList()
+ privateBookmarkIds = privIds
+ } catch (e: Exception) {
+ // Keep existing private IDs if decryption fails
+ }
+ }
+
+ // Remove unbookmarked event from appropriate list
+ if (!pubIds.contains(event.id)) {
+ publicEventState.removeItem(event.id)
+ }
+ if (!privateBookmarkIds.contains(event.id)) {
+ privateEventState.removeItem(event.id)
+ }
+ },
+ )
+ }
+ HorizontalDivider(thickness = 1.dp)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt
index 5cd4d73a7..f6676ae14 100644
--- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt
@@ -55,17 +55,30 @@ 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.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.core.Event
+import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
+import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
+import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
+import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
+import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
/**
* Note card with action buttons.
@@ -74,11 +87,23 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
fun FeedNoteCard(
event: Event,
relayManager: DesktopRelayConnectionManager,
+ localCache: DesktopLocalCache,
account: AccountState.LoggedIn?,
+ nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
onReply: () -> Unit,
+ onZapFeedback: (ZapFeedback) -> Unit,
onNavigateToProfile: (String) -> Unit = {},
onNavigateToThread: (String) -> Unit = {},
+ zapReceipts: List = emptyList(),
+ reactionCount: Int = 0,
+ replyCount: Int = 0,
+ repostCount: Int = 0,
+ bookmarkList: BookmarkListEvent? = null,
+ isBookmarked: Boolean = false,
+ onBookmarkChanged: (BookmarkListEvent) -> Unit = {},
) {
+ val zapAmountSats = zapReceipts.sumOf { it.amountSats }
+
Column(
modifier =
Modifier.clickable {
@@ -86,7 +111,7 @@ fun FeedNoteCard(
},
) {
NoteCard(
- note = event.toNoteDisplayData(),
+ note = event.toNoteDisplayData(localCache),
onAuthorClick = onNavigateToProfile,
)
@@ -95,9 +120,21 @@ fun FeedNoteCard(
NoteActionsRow(
event = event,
relayManager = relayManager,
+ localCache = localCache,
account = account,
+ nwcConnection = nwcConnection,
onReplyClick = onReply,
+ onZapFeedback = onZapFeedback,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
+ zapCount = zapReceipts.size,
+ zapAmountSats = zapAmountSats,
+ zapReceipts = zapReceipts,
+ reactionCount = reactionCount,
+ replyCount = replyCount,
+ repostCount = repostCount,
+ bookmarkList = bookmarkList,
+ isBookmarked = isBookmarked,
+ onBookmarkChanged = onBookmarkChanged,
)
}
}
@@ -106,10 +143,13 @@ fun FeedNoteCard(
@Composable
fun FeedScreen(
relayManager: DesktopRelayConnectionManager,
+ localCache: DesktopLocalCache,
account: AccountState.LoggedIn? = null,
+ nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
onCompose: () -> Unit = {},
onNavigateToProfile: (String) -> Unit = {},
onNavigateToThread: (String) -> Unit = {},
+ onZapFeedback: (ZapFeedback) -> Unit = {},
) {
val connectedRelays by relayManager.connectedRelays.collectAsState()
val relayStatuses by relayManager.relayStatuses.collectAsState()
@@ -125,8 +165,14 @@ fun FeedScreen(
}
val events by eventState.items.collectAsState()
var replyToEvent by remember { mutableStateOf(null) }
- var feedMode by remember { mutableStateOf(FeedMode.GLOBAL) }
+ var feedMode by remember { mutableStateOf(DesktopPreferences.feedMode) }
var followedUsers by remember { mutableStateOf>(emptySet()) }
+ var zapsByEvent by remember { mutableStateOf