zap updates

This commit is contained in:
nrobi144
2026-01-19 16:32:01 +02:00
parent e1412c1f97
commit 8f6de1d304
30 changed files with 5267 additions and 205 deletions
@@ -25,6 +25,7 @@ import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.cache.IChannel 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.isDebug
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
@@ -225,7 +226,7 @@ object LocalCache : ILocalCache, ICacheProvider {
val liveChatChannels = LargeCache<Address, LiveActivitiesChannel>() val liveChatChannels = LargeCache<Address, LiveActivitiesChannel>()
val ephemeralChannels = LargeCache<RoomId, EphemeralChatChannel>() val ephemeralChannels = LargeCache<RoomId, EphemeralChatChannel>()
val awaitingPaymentRequests = ConcurrentHashMap<HexKey, Pair<Note?, suspend (LnZapPaymentResponseEvent) -> Unit>>(10) val paymentTracker = NwcPaymentTracker()
val relayHints = HintIndexer() val relayHints = HintIndexer()
@@ -308,7 +309,7 @@ object LocalCache : ILocalCache, ICacheProvider {
fun load(keys: Set<String>): Set<User> = keys.mapNotNullTo(mutableSetOf(), ::checkGetOrCreateUser) fun load(keys: Set<String>): Set<User> = 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" } require(isValidHex(key = key)) { "$key is not a valid hex" }
return users.getOrCreate(key) { return users.getOrCreate(key) {
@@ -1976,7 +1977,7 @@ object LocalCache : ILocalCache, ICacheProvider {
zappedNote?.addZapPayment(note, null) zappedNote?.addZapPayment(note, null)
awaitingPaymentRequests.put(event.id, Pair(zappedNote, onResponse)) paymentTracker.registerRequest(event.id, zappedNote, onResponse)
refreshNewNoteObservers(note) refreshNewNoteObservers(note)
@@ -1992,9 +1993,10 @@ object LocalCache : ILocalCache, ICacheProvider {
wasVerified: Boolean, wasVerified: Boolean,
): Boolean { ): Boolean {
val requestId = event.requestId() 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) } val requestNote = requestId?.let { checkGetOrCreateNote(requestId) }
@@ -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 }
@@ -96,6 +96,28 @@ interface ICacheProvider {
* @return true if the event has been deleted, false otherwise * @return true if the event has been deleted, false otherwise
*/ */
fun hasBeenDeleted(event: Any): Boolean 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<Any> = 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?
} }
/** /**
@@ -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<SearchResult> {
if (input.isBlank()) return emptyList()
val trimmed = input.trim()
val results = mutableListOf<SearchResult>()
// 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
}
@@ -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()
}
@@ -90,6 +90,7 @@ class EventCollectionState<T : Any>(
mutex.withLock { mutex.withLock {
val itemId = getId(item) val itemId = getId(item)
if (itemId !in seenIds) { if (itemId !in seenIds) {
seenIds.add(itemId)
pendingItems.add(item) pendingItems.add(item)
scheduleBatchUpdate() scheduleBatchUpdate()
} }
@@ -108,6 +109,7 @@ class EventCollectionState<T : Any>(
mutex.withLock { mutex.withLock {
val newItems = items.filter { getId(it) !in seenIds } val newItems = items.filter { getId(it) !in seenIds }
if (newItems.isNotEmpty()) { if (newItems.isNotEmpty()) {
newItems.forEach { seenIds.add(getId(it)) }
pendingItems.addAll(newItems) pendingItems.addAll(newItems)
scheduleBatchUpdate() scheduleBatchUpdate()
} }
@@ -191,8 +193,7 @@ class EventCollectionState<T : Any>(
mutex.withLock { mutex.withLock {
if (pendingItems.isEmpty()) return if (pendingItems.isEmpty()) return
// Add pending IDs to seenIds // seenIds already updated in addItem/addItems
pendingItems.forEach { seenIds.add(getId(it)) }
// Merge with existing items // Merge with existing items
val merged = _items.value + pendingItems val merged = _items.value + pendingItems
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.commons.ui.components package com.vitorpamplona.amethyst.commons.ui.components
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Face import androidx.compose.material.icons.filled.Face
@@ -62,8 +63,10 @@ fun UserAvatar(
loadRobohash: Boolean = true, loadRobohash: Boolean = true,
) { ) {
val avatarModifier = val avatarModifier =
remember(size) { remember(size, modifier) {
modifier.clip(shape = CircleShape) modifier
.size(size)
.clip(shape = CircleShape)
} }
if (pictureUrl != null && loadProfilePicture) { if (pictureUrl != null && loadProfilePicture) {
@@ -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,
)
}
}
}
@@ -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<String> = _searchText.asStateFlow()
private val _bech32Results = MutableStateFlow<List<SearchResult>>(emptyList())
val bech32Results: StateFlow<List<SearchResult>> = _bech32Results.asStateFlow()
private val _cachedUserResults = MutableStateFlow<List<User>>(emptyList())
val cachedUserResults: StateFlow<List<User>> = _cachedUserResults.asStateFlow()
private val _relaySearchResults = MutableStateFlow<List<User>>(emptyList())
val relaySearchResults: StateFlow<List<User>> = _relaySearchResults.asStateFlow()
private val _isSearchingRelays = MutableStateFlow(false)
val isSearchingRelays: StateFlow<Boolean> = _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<User>
} 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
}
}
}
@@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip19Bech32.toNsec import com.vitorpamplona.quartz.nip19Bech32.toNsec
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -68,6 +69,9 @@ class AccountManager private constructor(
private val _accountState = MutableStateFlow<AccountState>(AccountState.LoggedOut) private val _accountState = MutableStateFlow<AccountState>(AccountState.LoggedOut)
val accountState: StateFlow<AccountState> = _accountState.asStateFlow() val accountState: StateFlow<AccountState> = _accountState.asStateFlow()
private val _nwcConnection = MutableStateFlow<Nip47WalletConnect.Nip47URINorm?>(null)
val nwcConnection: StateFlow<Nip47WalletConnect.Nip47URINorm?> = _nwcConnection.asStateFlow()
/** /**
* Loads the last saved account from secure storage. * Loads the last saved account from secure storage.
* Call on app startup. * Call on app startup.
@@ -205,6 +209,47 @@ class AccountManager private constructor(
fun currentAccount(): AccountState.LoggedIn? = _accountState.value as? AccountState.LoggedIn fun currentAccount(): AccountState.LoggedIn? = _accountState.value as? AccountState.LoggedIn
// NWC (Nostr Wallet Connect) methods
fun hasNwcSetup(): Boolean = _nwcConnection.value != null
fun setNwcConnection(uri: String): Result<Nip47WalletConnect.Nip47URINorm> =
try {
val parsed = Nip47WalletConnect.parse(uri)
_nwcConnection.value = parsed
saveNwcUri(uri)
Result.success(parsed)
} catch (e: Exception) {
Result.failure(e)
}
fun clearNwcConnection() {
_nwcConnection.value = null
getNwcFile().delete()
}
fun loadNwcConnection() {
val uri = getNwcFile().takeIf { it.exists() }?.readText()?.trim()
if (!uri.isNullOrEmpty()) {
try {
_nwcConnection.value = Nip47WalletConnect.parse(uri)
} catch (e: Exception) {
// Invalid stored URI, clear it
getNwcFile().delete()
}
}
}
private fun saveNwcUri(uri: String) {
val file = getNwcFile()
file.parentFile?.mkdirs()
file.writeText(uri)
}
private fun getNwcFile(): java.io.File {
val homeDir = System.getProperty("user.home")
return java.io.File(homeDir, ".amethyst/nwc_connection.txt")
}
// Simple file-based storage for last npub (non-sensitive data) // Simple file-based storage for last npub (non-sensitive data)
private fun getLastNpub(): String? { private fun getLastNpub(): String? {
val file = getPrefsFile() val file = getPrefsFile()
@@ -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)
}
@@ -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<NormalizedRelayUrl>,
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<NormalizedRelayUrl>,
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)
}
}
}
@@ -107,6 +107,61 @@ open class RelayConnectionManager(
send(event, connected) send(event, connected)
} }
/**
* Sends an event to a specific relay (for NWC).
* Adds the relay if not already in the list.
*/
fun sendToRelay(
relay: NormalizedRelayUrl,
event: Event,
) {
if (relay !in availableRelays.value) {
updateRelayStatus(relay) { it.copy(connected = false, error = null) }
}
client.send(event, setOf(relay))
}
/**
* Subscribes on a specific relay (for NWC).
* Adds the relay if not already in the list.
*/
fun subscribeOnRelay(
relay: NormalizedRelayUrl,
subId: String,
filters: List<Filter>,
onEvent: (Event, NormalizedRelayUrl) -> Unit,
) {
if (relay !in availableRelays.value) {
updateRelayStatus(relay) { it.copy(connected = false, error = null) }
}
val filterMap = mapOf(relay to filters)
client.openReqSubscription(
subId = subId,
filters = filterMap,
listener =
object : IRequestListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
onEvent(event, relay)
}
},
)
}
/**
* Closes a subscription on a specific relay.
*/
fun closeSubscription(
relay: NormalizedRelayUrl,
subId: String,
) {
client.close(subId)
}
private fun updateRelayStatus( private fun updateRelayStatus(
url: NormalizedRelayUrl, url: NormalizedRelayUrl,
update: (RelayStatus) -> RelayStatus, update: (RelayStatus) -> RelayStatus,
@@ -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
}
}
}
@@ -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<HexKey, PendingRequest>(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
}
@@ -131,3 +131,187 @@ fun createThreadRepliesSubscription(
onEvent = onEvent, onEvent = onEvent,
onEose = onEose, onEose = onEose,
) )
/**
* Creates a NIP-50 search subscription for user profiles.
* Requires NIP-50 compatible relays (e.g., relay.nostr.band, nostr.wine).
*
* @param searchQuery Text to search for in user profiles
* @param limit Maximum results to return
*/
fun createSearchPeopleSubscription(
relays: Set<NormalizedRelayUrl>,
searchQuery: String,
limit: Int = 50,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig? {
if (searchQuery.isBlank()) return null
return SubscriptionConfig(
subId = generateSubId("search-people-${searchQuery.take(8)}"),
filters = listOf(FilterBuilders.searchPeople(searchQuery, limit)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
}
/**
* Creates a NIP-50 search subscription for text notes.
* Requires NIP-50 compatible relays.
*
* @param searchQuery Text to search for in notes
* @param limit Maximum results to return
*/
fun createSearchNotesSubscription(
relays: Set<NormalizedRelayUrl>,
searchQuery: String,
limit: Int = 50,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig? {
if (searchQuery.isBlank()) return null
return SubscriptionConfig(
subId = generateSubId("search-notes-${searchQuery.take(8)}"),
filters = listOf(FilterBuilders.searchNotes(searchQuery, limit)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
}
/**
* Creates a subscription for zap receipts (kind 9735) for specific events.
*
* @param eventIds Event IDs to get zaps for
* @param limit Maximum zaps per event
*/
fun createZapsSubscription(
relays: Set<NormalizedRelayUrl>,
eventIds: List<String>,
limit: Int = 100,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig? {
if (eventIds.isEmpty()) return null
return SubscriptionConfig(
subId = generateSubId("zaps-${eventIds.first().take(8)}"),
filters = listOf(FilterBuilders.zapsForEvents(eventIds, limit)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
}
/**
* Creates a subscription for reactions (kind 7) for specific events.
*
* @param eventIds Event IDs to get reactions for
* @param limit Maximum reactions per event
*/
fun createReactionsSubscription(
relays: Set<NormalizedRelayUrl>,
eventIds: List<String>,
limit: Int = 100,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig? {
if (eventIds.isEmpty()) return null
return SubscriptionConfig(
subId = generateSubId("reactions-${eventIds.first().take(8)}"),
filters = listOf(FilterBuilders.reactionsForEvents(eventIds, limit)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
}
/**
* Creates a subscription for replies (kind 1) to specific events.
*
* @param eventIds Event IDs to get replies for
* @param limit Maximum replies per event
*/
fun createRepliesSubscription(
relays: Set<NormalizedRelayUrl>,
eventIds: List<String>,
limit: Int = 100,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig? {
if (eventIds.isEmpty()) return null
return SubscriptionConfig(
subId = generateSubId("replies-${eventIds.first().take(8)}"),
filters = listOf(FilterBuilders.repliesForEvents(eventIds, limit)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
}
/**
* Creates a subscription for reposts (kind 6) of specific events.
*
* @param eventIds Event IDs to get reposts for
* @param limit Maximum reposts per event
*/
fun createRepostsSubscription(
relays: Set<NormalizedRelayUrl>,
eventIds: List<String>,
limit: Int = 100,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig? {
if (eventIds.isEmpty()) return null
return SubscriptionConfig(
subId = generateSubId("reposts-${eventIds.first().take(8)}"),
filters = listOf(FilterBuilders.repostsForEvents(eventIds, limit)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
}
/**
* Creates a subscription config for global long-form content (kind 30023, NIP-23).
*/
fun createLongFormFeedSubscription(
relays: Set<NormalizedRelayUrl>,
limit: Int = 30,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig =
SubscriptionConfig(
subId = generateSubId("longform-feed"),
filters = listOf(FilterBuilders.longFormGlobal(limit = limit)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
/**
* Creates a subscription config for long-form content from followed users.
*/
fun createFollowingLongFormFeedSubscription(
relays: Set<NormalizedRelayUrl>,
followedUsers: List<String>,
limit: Int = 30,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig? {
if (followedUsers.isEmpty()) return null
return SubscriptionConfig(
subId = generateSubId("longform-following"),
filters = listOf(FilterBuilders.longFormFromAuthors(followedUsers, limit = limit)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
}
@@ -83,6 +83,18 @@ object FilterBuilders {
limit = 1, limit = 1,
) )
/**
* Creates a filter for user metadata (kind 0) from multiple authors.
*
* @param pubKeyHexList List of author public keys (hex-encoded, 64 chars each)
* @return Filter for user metadata
*/
fun userMetadataBatch(pubKeyHexList: List<String>): Filter =
Filter(
kinds = listOf(0), // MetadataEvent.KIND
authors = pubKeyHexList,
)
/** /**
* Creates a filter for contact list (kind 3) from a specific author. * Creates a filter for contact list (kind 3) from a specific author.
* *
@@ -256,6 +268,155 @@ object FilterBuilders {
since = since, since = since,
until = until, until = until,
) )
/**
* Creates a NIP-50 search filter for user metadata (kind 0).
* Searches user profiles by name, displayName, about, nip05, etc.
* Requires a NIP-50 compatible relay (e.g., relay.nostr.band, nostr.wine).
*
* @param searchQuery The text to search for in user profiles
* @param limit Maximum number of results to return
* @return Filter for NIP-50 search
*/
fun searchPeople(
searchQuery: String,
limit: Int = 50,
): Filter =
Filter(
kinds = listOf(0), // MetadataEvent.KIND
search = searchQuery,
limit = limit,
)
/**
* Creates a NIP-50 search filter for text notes (kind 1).
* Searches note content.
* Requires a NIP-50 compatible relay.
*
* @param searchQuery The text to search for in notes
* @param limit Maximum number of results to return
* @return Filter for NIP-50 search
*/
fun searchNotes(
searchQuery: String,
limit: Int = 50,
): Filter =
Filter(
kinds = listOf(1), // TextNoteEvent.KIND
search = searchQuery,
limit = limit,
)
/**
* Creates a filter for zap receipts (kind 9735) for specific events.
*
* @param eventIds List of event IDs to get zaps for
* @param limit Maximum number of events to request
* @return Filter for zap receipts
*/
fun zapsForEvents(
eventIds: List<String>,
limit: Int? = null,
): Filter =
Filter(
kinds = listOf(9735), // LnZapEvent.KIND
tags = mapOf("e" to eventIds),
limit = limit,
)
/**
* Creates a filter for reactions (kind 7) for specific events.
*
* @param eventIds List of event IDs to get reactions for
* @param limit Maximum number of events to request
* @return Filter for reactions
*/
fun reactionsForEvents(
eventIds: List<String>,
limit: Int? = null,
): Filter =
Filter(
kinds = listOf(7), // ReactionEvent.KIND
tags = mapOf("e" to eventIds),
limit = limit,
)
/**
* Creates a filter for replies (kind 1) to specific events.
*
* @param eventIds List of event IDs to get replies for
* @param limit Maximum number of events to request
* @return Filter for replies
*/
fun repliesForEvents(
eventIds: List<String>,
limit: Int? = null,
): Filter =
Filter(
kinds = listOf(1), // TextNoteEvent.KIND
tags = mapOf("e" to eventIds),
limit = limit,
)
/**
* Creates a filter for reposts (kind 6) of specific events.
*
* @param eventIds List of event IDs to get reposts for
* @param limit Maximum number of events to request
* @return Filter for reposts
*/
fun repostsForEvents(
eventIds: List<String>,
limit: Int? = null,
): Filter =
Filter(
kinds = listOf(6), // RepostEvent.KIND
tags = mapOf("e" to eventIds),
limit = limit,
)
/**
* Creates a filter for long-form content (kind 30023, NIP-23).
*
* @param limit Maximum number of events to request
* @param since Timestamp for events with publication time ≥ this value
* @param until Timestamp for events with publication time ≤ this value
* @return Filter for long-form content
*/
fun longFormGlobal(
limit: Int? = null,
since: Long? = null,
until: Long? = null,
): Filter =
Filter(
kinds = listOf(30023), // LongTextNoteEvent.KIND
limit = limit,
since = since,
until = until,
)
/**
* Creates a filter for long-form content (kind 30023) from specific authors.
*
* @param authors List of author public keys (hex-encoded, 64 chars each)
* @param limit Maximum number of events to request
* @param since Timestamp for events with publication time ≥ this value
* @param until Timestamp for events with publication time ≤ this value
* @return Filter for long-form content from specified authors
*/
fun longFormFromAuthors(
authors: List<String>,
limit: Int? = null,
since: Long? = null,
until: Long? = null,
): Filter =
Filter(
kinds = listOf(30023), // LongTextNoteEvent.KIND
authors = authors,
limit = limit,
since = since,
until = until,
)
} }
/** /**
@@ -26,20 +26,50 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/** /**
* Creates a subscription config for user metadata (kind 0). * Creates a subscription config for user metadata (kind 0).
* Returns null if the pubKeyHex is invalid (not 64 characters).
*/ */
fun createMetadataSubscription( fun createMetadataSubscription(
relays: Set<NormalizedRelayUrl>, relays: Set<NormalizedRelayUrl>,
pubKeyHex: String, pubKeyHex: String,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit, onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> }, onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig = ): SubscriptionConfig? {
SubscriptionConfig( // Validate pubkey length
if (pubKeyHex.length != 64) {
return null
}
return SubscriptionConfig(
subId = generateSubId("meta-${pubKeyHex.take(8)}"), subId = generateSubId("meta-${pubKeyHex.take(8)}"),
filters = listOf(FilterBuilders.userMetadata(pubKeyHex)), filters = listOf(FilterBuilders.userMetadata(pubKeyHex)),
relays = relays, relays = relays,
onEvent = onEvent, onEvent = onEvent,
onEose = onEose, onEose = onEose,
) )
}
/**
* Creates a subscription config for metadata of multiple users (kind 0).
* Useful for batch-fetching author profiles.
* Filters out any invalid pubkeys (not 64 characters).
*/
fun createBatchMetadataSubscription(
relays: Set<NormalizedRelayUrl>,
pubKeyHexList: List<String>,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig? {
// Filter out invalid pubkeys
val validPubkeys = pubKeyHexList.filter { it.length == 64 }
if (validPubkeys.isEmpty()) return null
return SubscriptionConfig(
subId = generateSubId("meta-batch-${validPubkeys.size}"),
filters = listOf(FilterBuilders.userMetadataBatch(validPubkeys)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
}
/** /**
* Creates a subscription config for user posts (kind 1). * Creates a subscription config for user posts (kind 1).
@@ -20,6 +20,8 @@
*/ */
package com.vitorpamplona.amethyst.commons.util 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.amethyst.commons.ui.note.NoteDisplayData
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull 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. * Extension to convert Event to NoteDisplayData for the shared NoteCard.
*/ */
fun Event.toNoteDisplayData(): NoteDisplayData { fun Event.toNoteDisplayData(cache: ICacheProvider? = null): NoteDisplayData {
val npub = val npub =
try { try {
pubKey.hexToByteArrayOrNull()?.toNpub() ?: pubKey.take(16) + "..." pubKey.hexToByteArrayOrNull()?.toNpub() ?: pubKey.take(16) + "..."
@@ -36,10 +38,13 @@ fun Event.toNoteDisplayData(): NoteDisplayData {
pubKey.take(16) + "..." pubKey.take(16) + "..."
} }
val pictureUrl = (cache?.getUserIfExists(pubKey) as? User)?.profilePicture()
return NoteDisplayData( return NoteDisplayData(
id = id, id = id,
pubKeyHex = pubKey, pubKeyHex = pubKey,
pubKeyDisplay = npub, pubKeyDisplay = npub,
profilePictureUrl = pictureUrl,
content = content, content = content,
createdAt = createdAt, createdAt = createdAt,
) )
@@ -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)
}
}
@@ -34,6 +34,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons 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.Email
import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Notifications 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.Search
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -50,12 +52,15 @@ import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider import androidx.compose.material3.VerticalDivider
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf 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.profile.ProfileInfoCard
import com.vitorpamplona.amethyst.commons.ui.relay.RelayStatusCard import com.vitorpamplona.amethyst.commons.ui.relay.RelayStatusCard
import com.vitorpamplona.amethyst.commons.ui.screens.MessagesPlaceholder 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.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen
import com.vitorpamplona.amethyst.desktop.ui.ComposeNoteDialog import com.vitorpamplona.amethyst.desktop.ui.ComposeNoteDialog
import com.vitorpamplona.amethyst.desktop.ui.FeedScreen import com.vitorpamplona.amethyst.desktop.ui.FeedScreen
import com.vitorpamplona.amethyst.desktop.ui.LoginScreen import com.vitorpamplona.amethyst.desktop.ui.LoginScreen
import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen 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.ThreadScreen
import com.vitorpamplona.amethyst.desktop.ui.UserProfileScreen 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -99,8 +109,12 @@ private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
sealed class DesktopScreen { sealed class DesktopScreen {
object Feed : DesktopScreen() object Feed : DesktopScreen()
object Reads : DesktopScreen()
object Search : DesktopScreen() object Search : DesktopScreen()
object Bookmarks : DesktopScreen()
object Messages : DesktopScreen() object Messages : DesktopScreen()
object Notifications : DesktopScreen() object Notifications : DesktopScreen()
@@ -127,6 +141,8 @@ fun main() =
position = WindowPosition.Aligned(Alignment.Center), position = WindowPosition.Aligned(Alignment.Center),
) )
var showComposeDialog by remember { mutableStateOf(false) } var showComposeDialog by remember { mutableStateOf(false) }
var replyToNote by remember { mutableStateOf<com.vitorpamplona.quartz.nip01Core.core.Event?>(null) }
var currentScreen by remember { mutableStateOf<DesktopScreen>(DesktopScreen.Feed) }
Window( Window(
onCloseRequest = ::exitApplication, onCloseRequest = ::exitApplication,
@@ -154,7 +170,7 @@ fun main() =
} else { } else {
KeyShortcut(Key.Comma, ctrl = true) KeyShortcut(Key.Comma, ctrl = true)
}, },
onClick = { /* TODO: Open settings */ }, onClick = { currentScreen = DesktopScreen.Settings },
) )
Separator() Separator()
Item( Item(
@@ -202,21 +218,35 @@ fun main() =
} }
App( App(
currentScreen = currentScreen,
onScreenChange = { currentScreen = it },
showComposeDialog = showComposeDialog, showComposeDialog = showComposeDialog,
onShowComposeDialog = { showComposeDialog = true }, onShowComposeDialog = { showComposeDialog = true },
onDismissComposeDialog = { showComposeDialog = false }, onShowReplyDialog = { event ->
replyToNote = event
showComposeDialog = true
},
onDismissComposeDialog = {
showComposeDialog = false
replyToNote = null
},
replyToNote = replyToNote,
) )
} }
} }
@Composable @Composable
fun App( fun App(
currentScreen: DesktopScreen,
onScreenChange: (DesktopScreen) -> Unit,
showComposeDialog: Boolean, showComposeDialog: Boolean,
onShowComposeDialog: () -> Unit, onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
onDismissComposeDialog: () -> Unit, onDismissComposeDialog: () -> Unit,
replyToNote: com.vitorpamplona.quartz.nip01Core.core.Event?,
) { ) {
var currentScreen by remember { mutableStateOf<DesktopScreen>(DesktopScreen.Feed) }
val relayManager = remember { DesktopRelayConnectionManager() } val relayManager = remember { DesktopRelayConnectionManager() }
val localCache = remember { DesktopLocalCache() }
val accountManager = remember { AccountManager.create() } val accountManager = remember { AccountManager.create() }
val accountState by accountManager.accountState.collectAsState() val accountState by accountManager.accountState.collectAsState()
val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) } val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) }
@@ -246,19 +276,28 @@ fun App(
is AccountState.LoggedOut -> { is AccountState.LoggedOut -> {
LoginScreen( LoginScreen(
accountManager = accountManager, accountManager = accountManager,
onLoginSuccess = { currentScreen = DesktopScreen.Feed }, onLoginSuccess = { onScreenChange(DesktopScreen.Feed) },
) )
} }
is AccountState.LoggedIn -> { is AccountState.LoggedIn -> {
val account = accountState as 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( MainContent(
currentScreen = currentScreen, currentScreen = currentScreen,
onScreenChange = { currentScreen = it }, onScreenChange = onScreenChange,
relayManager = relayManager, relayManager = relayManager,
localCache = localCache,
accountManager = accountManager, accountManager = accountManager,
account = account, account = account,
nwcConnection = nwcConnection,
onShowComposeDialog = onShowComposeDialog, onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
) )
// Compose dialog // Compose dialog
@@ -267,6 +306,7 @@ fun App(
onDismiss = onDismissComposeDialog, onDismiss = onDismissComposeDialog,
relayManager = relayManager, relayManager = relayManager,
account = account, account = account,
replyTo = replyToNote,
) )
} }
} }
@@ -280,127 +320,218 @@ fun MainContent(
currentScreen: DesktopScreen, currentScreen: DesktopScreen,
onScreenChange: (DesktopScreen) -> Unit, onScreenChange: (DesktopScreen) -> Unit,
relayManager: DesktopRelayConnectionManager, relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache,
accountManager: AccountManager, accountManager: AccountManager,
account: AccountState.LoggedIn, account: AccountState.LoggedIn,
nwcConnection: Nip47WalletConnect.Nip47URINorm?,
onShowComposeDialog: () -> Unit, onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
) { ) {
Row(Modifier.fillMaxSize()) { val snackbarHostState = remember { SnackbarHostState() }
// Sidebar Navigation val scope = rememberCoroutineScope()
NavigationRail(
modifier = Modifier.width(80.dp).fillMaxHeight(),
containerColor = MaterialTheme.colorScheme.surfaceVariant,
) {
Spacer(Modifier.height(16.dp))
NavigationRailItem( val onZapFeedback: (ZapFeedback) -> Unit = { feedback ->
icon = { Icon(Icons.Default.Home, contentDescription = "Feed") }, scope.launch {
label = { Text("Feed") }, val message =
selected = currentScreen == DesktopScreen.Feed, when (feedback) {
onClick = { onScreenChange(DesktopScreen.Feed) }, is ZapFeedback.Success -> "Zapped ${feedback.amountSats} sats"
) is ZapFeedback.ExternalWallet -> "Invoice sent to wallet (${feedback.amountSats} sats)"
is ZapFeedback.Error -> "Zap failed: ${feedback.message}"
NavigationRailItem( is ZapFeedback.Timeout -> "Zap timed out"
icon = { Icon(Icons.Default.Search, contentDescription = "Search") }, is ZapFeedback.NoLightningAddress -> "User has no lightning address"
label = { Text("Search") }, }
selected = currentScreen == DesktopScreen.Search, snackbarHostState.showSnackbar(message)
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))
} }
}
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 NavigationRailItem(
Box( icon = { Icon(Icons.Default.Home, contentDescription = "Feed") },
modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp), label = { Text("Feed") },
) { selected = currentScreen == DesktopScreen.Feed,
when (currentScreen) { onClick = { onScreenChange(DesktopScreen.Feed) },
DesktopScreen.Feed -> )
FeedScreen(
relayManager = relayManager, NavigationRailItem(
account = account, icon = { Icon(Icons.AutoMirrored.Filled.Article, contentDescription = "Reads") },
onCompose = onShowComposeDialog, label = { Text("Reads") },
onNavigateToProfile = { pubKeyHex -> selected = currentScreen == DesktopScreen.Reads,
onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) onClick = { onScreenChange(DesktopScreen.Reads) },
}, )
onNavigateToThread = { noteId ->
onScreenChange(DesktopScreen.Thread(noteId)) NavigationRailItem(
}, icon = { Icon(Icons.Default.Search, contentDescription = "Search") },
) label = { Text("Search") },
DesktopScreen.Search -> SearchPlaceholder() selected = currentScreen == DesktopScreen.Search,
DesktopScreen.Messages -> MessagesPlaceholder() onClick = { onScreenChange(DesktopScreen.Search) },
DesktopScreen.Notifications -> NotificationsScreen(relayManager, account) )
DesktopScreen.MyProfile ->
UserProfileScreen( NavigationRailItem(
pubKeyHex = account.pubKeyHex, icon = { Icon(com.vitorpamplona.amethyst.commons.icons.Bookmark, contentDescription = "Bookmarks") },
relayManager = relayManager, label = { Text("Bookmarks") },
account = account, selected = currentScreen == DesktopScreen.Bookmarks,
onBack = { onScreenChange(DesktopScreen.Feed) }, onClick = { onScreenChange(DesktopScreen.Bookmarks) },
onCompose = onShowComposeDialog, )
onNavigateToProfile = { pubKeyHex ->
onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) NavigationRailItem(
}, icon = { Icon(Icons.Default.Email, contentDescription = "Messages") },
) label = { Text("DMs") },
is DesktopScreen.UserProfile -> selected = currentScreen == DesktopScreen.Messages,
UserProfileScreen( onClick = { onScreenChange(DesktopScreen.Messages) },
pubKeyHex = currentScreen.pubKeyHex, )
relayManager = relayManager,
account = account, NavigationRailItem(
onBack = { onScreenChange(DesktopScreen.Feed) }, icon = { Icon(Icons.Default.Notifications, contentDescription = "Notifications") },
onCompose = onShowComposeDialog, label = { Text("Alerts") },
onNavigateToProfile = { pubKeyHex -> selected = currentScreen == DesktopScreen.Notifications,
onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) onClick = { onScreenChange(DesktopScreen.Notifications) },
}, )
)
is DesktopScreen.Thread -> NavigationRailItem(
ThreadScreen( icon = { Icon(Icons.Default.Person, contentDescription = "Profile") },
noteId = currentScreen.noteId, label = { Text("Profile") },
relayManager = relayManager, selected = currentScreen == DesktopScreen.MyProfile || currentScreen is DesktopScreen.UserProfile,
account = account, onClick = { onScreenChange(DesktopScreen.MyProfile) },
onBack = { onScreenChange(DesktopScreen.Feed) }, )
onNavigateToProfile = { pubKeyHex ->
onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) Spacer(Modifier.weight(1f))
},
onNavigateToThread = { noteId -> HorizontalDivider(Modifier.padding(horizontal = 16.dp))
onScreenChange(DesktopScreen.Thread(noteId))
}, NavigationRailItem(
) icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
DesktopScreen.Settings -> RelaySettingsScreen(relayManager, account) 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( fun RelaySettingsScreen(
relayManager: DesktopRelayConnectionManager, relayManager: DesktopRelayConnectionManager,
account: AccountState.LoggedIn, account: AccountState.LoggedIn,
accountManager: AccountManager,
) { ) {
val relayStatuses by relayManager.relayStatuses.collectAsState() val relayStatuses by relayManager.relayStatuses.collectAsState()
val connectedRelays by relayManager.connectedRelays.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState()
val nwcConnection by accountManager.nwcConnection.collectAsState()
var newRelayUrl by remember { mutableStateOf("") } var newRelayUrl by remember { mutableStateOf("") }
var nwcInput by remember { mutableStateOf("") }
var nwcError by remember { mutableStateOf<String?>(null) }
// Load NWC on first composition
LaunchedEffect(Unit) {
accountManager.loadNwcConnection()
}
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
Text( Text(
@@ -457,6 +597,88 @@ fun RelaySettingsScreen(
Spacer(Modifier.height(24.dp)) 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) // Developer Settings Section (only in debug mode)
if (DebugConfig.isDebugMode) { if (DebugConfig.isDebugMode) {
com.vitorpamplona.amethyst.desktop.ui com.vitorpamplona.amethyst.desktop.ui
@@ -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<HexKey, User>()
private val notes = ConcurrentHashMap<HexKey, Note>()
private val deletedEvents = ConcurrentHashMap.newKeySet<HexKey>()
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<User> {
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<Note>) {
eventStream.emitNewNotes(notes)
}
/**
* Emits deleted notes to observers.
*/
suspend fun emitDeletedNotes(notes: Set<Note>) {
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<Set<Note>>(replay = 0)
private val _deletedEventBundles = MutableSharedFlow<Set<Note>>(replay = 0)
override val newEventBundles: SharedFlow<Set<Note>> = _newEventBundles
override val deletedEventBundles: SharedFlow<Set<Note>> = _deletedEventBundles
suspend fun emitNewNotes(notes: Set<Note>) {
_newEventBundles.emit(notes)
}
suspend fun emitDeletedNotes(notes: Set<Note>) {
_deletedEventBundles.emit(notes)
}
}
@@ -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}")
}
}
}
@@ -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<BookmarkListEvent?>(null) }
var publicBookmarkIds by remember { mutableStateOf<List<String>>(emptyList()) }
var privateBookmarkIds by remember { mutableStateOf<List<String>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var hasReceivedEose by remember { mutableStateOf(false) }
// State for fetched bookmark events
val publicEventState =
remember(account.pubKeyHex) {
EventCollectionState<Event>(
getId = { it.id },
maxSize = 100,
scope = scope,
)
}
val publicEvents by publicEventState.items.collectAsState()
val privateEventState =
remember(account.pubKeyHex) {
EventCollectionState<Event>(
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<EventBookmark>()
.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<EventBookmark>()
?.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<EventBookmark>()
.map { it.eventId }
publicBookmarkIds = pubIds
// Decrypt and update private bookmark IDs
scope.launch {
try {
val privateBookmarks = newList.privateBookmarks(account.signer)
val privIds =
privateBookmarks
?.filterIsInstance<EventBookmark>()
?.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)
}
}
}
}
}
}
@@ -55,17 +55,30 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.account.AccountState import com.vitorpamplona.amethyst.commons.account.AccountState
import com.vitorpamplona.amethyst.commons.state.EventCollectionState import com.vitorpamplona.amethyst.commons.state.EventCollectionState
import com.vitorpamplona.amethyst.commons.subscriptions.FeedMode 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.createContactListSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createFollowingFeedSubscription import com.vitorpamplona.amethyst.commons.subscriptions.createFollowingFeedSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createGlobalFeedSubscription 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.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.note.NoteCard import com.vitorpamplona.amethyst.commons.ui.note.NoteCard
import com.vitorpamplona.amethyst.commons.util.toNoteDisplayData 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.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent 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. * Note card with action buttons.
@@ -74,11 +87,23 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
fun FeedNoteCard( fun FeedNoteCard(
event: Event, event: Event,
relayManager: DesktopRelayConnectionManager, relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache,
account: AccountState.LoggedIn?, account: AccountState.LoggedIn?,
nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
onReply: () -> Unit, onReply: () -> Unit,
onZapFeedback: (ZapFeedback) -> Unit,
onNavigateToProfile: (String) -> Unit = {}, onNavigateToProfile: (String) -> Unit = {},
onNavigateToThread: (String) -> Unit = {}, onNavigateToThread: (String) -> Unit = {},
zapReceipts: List<ZapReceipt> = 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( Column(
modifier = modifier =
Modifier.clickable { Modifier.clickable {
@@ -86,7 +111,7 @@ fun FeedNoteCard(
}, },
) { ) {
NoteCard( NoteCard(
note = event.toNoteDisplayData(), note = event.toNoteDisplayData(localCache),
onAuthorClick = onNavigateToProfile, onAuthorClick = onNavigateToProfile,
) )
@@ -95,9 +120,21 @@ fun FeedNoteCard(
NoteActionsRow( NoteActionsRow(
event = event, event = event,
relayManager = relayManager, relayManager = relayManager,
localCache = localCache,
account = account, account = account,
nwcConnection = nwcConnection,
onReplyClick = onReply, onReplyClick = onReply,
onZapFeedback = onZapFeedback,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), 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 @Composable
fun FeedScreen( fun FeedScreen(
relayManager: DesktopRelayConnectionManager, relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache,
account: AccountState.LoggedIn? = null, account: AccountState.LoggedIn? = null,
nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
onCompose: () -> Unit = {}, onCompose: () -> Unit = {},
onNavigateToProfile: (String) -> Unit = {}, onNavigateToProfile: (String) -> Unit = {},
onNavigateToThread: (String) -> Unit = {}, onNavigateToThread: (String) -> Unit = {},
onZapFeedback: (ZapFeedback) -> Unit = {},
) { ) {
val connectedRelays by relayManager.connectedRelays.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState()
val relayStatuses by relayManager.relayStatuses.collectAsState() val relayStatuses by relayManager.relayStatuses.collectAsState()
@@ -125,8 +165,14 @@ fun FeedScreen(
} }
val events by eventState.items.collectAsState() val events by eventState.items.collectAsState()
var replyToEvent by remember { mutableStateOf<Event?>(null) } var replyToEvent by remember { mutableStateOf<Event?>(null) }
var feedMode by remember { mutableStateOf(FeedMode.GLOBAL) } var feedMode by remember { mutableStateOf(DesktopPreferences.feedMode) }
var followedUsers by remember { mutableStateOf<Set<String>>(emptySet()) } var followedUsers by remember { mutableStateOf<Set<String>>(emptySet()) }
var zapsByEvent by remember { mutableStateOf<Map<String, List<ZapReceipt>>>(emptyMap()) }
var reactionsByEvent by remember { mutableStateOf<Map<String, Int>>(emptyMap()) }
var repliesByEvent by remember { mutableStateOf<Map<String, Int>>(emptyMap()) }
var repostsByEvent by remember { mutableStateOf<Map<String, Int>>(emptyMap()) }
var bookmarkList by remember { mutableStateOf<BookmarkListEvent?>(null) }
var bookmarkedEventIds by remember { mutableStateOf<Set<String>>(emptySet()) }
// Track EOSE to know when initial load is complete // Track EOSE to know when initial load is complete
var eoseReceivedCount by remember { mutableStateOf(0) } var eoseReceivedCount by remember { mutableStateOf(0) }
@@ -150,6 +196,41 @@ fun FeedScreen(
} }
} }
// Load user's bookmark list
rememberSubscription(relayStatuses, account, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty() && account != null) {
com.vitorpamplona.amethyst.commons.subscriptions.SubscriptionConfig(
subId = "bookmarks-${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<com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark>()
.map { it.eventId }
.toSet()
bookmarkedEventIds = pubIds
}
},
onEose = { _, _ -> },
)
} else {
null
}
}
// Clear events and reset EOSE when feed mode changes // Clear events and reset EOSE when feed mode changes
remember(feedMode) { remember(feedMode) {
eventState.clear() eventState.clear()
@@ -168,6 +249,10 @@ fun FeedScreen(
createGlobalFeedSubscription( createGlobalFeedSubscription(
relays = configuredRelays, relays = configuredRelays,
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
// Store metadata events in cache
if (event is MetadataEvent) {
localCache.consumeMetadata(event)
}
eventState.addItem(event) eventState.addItem(event)
}, },
onEose = { _, _ -> onEose = { _, _ ->
@@ -181,6 +266,10 @@ fun FeedScreen(
relays = configuredRelays, relays = configuredRelays,
followedUsers = followedUsers.toList(), followedUsers = followedUsers.toList(),
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
// Store metadata events in cache
if (event is MetadataEvent) {
localCache.consumeMetadata(event)
}
eventState.addItem(event) eventState.addItem(event)
}, },
onEose = { _, _ -> onEose = { _, _ ->
@@ -194,6 +283,164 @@ fun FeedScreen(
} }
} }
// Subscribe to zaps for visible events
val eventIds = events.map { it.id }
rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
return@rememberSubscription null
}
createZapsSubscription(
relays = configuredRelays,
eventIds = eventIds,
onEvent = { event, _, _, _ ->
if (event is LnZapEvent) {
val receipt = event.toZapReceipt(localCache) ?: return@createZapsSubscription
val targetEventId = event.zappedPost().firstOrNull() ?: return@createZapsSubscription
zapsByEvent =
zapsByEvent.toMutableMap().apply {
val existing = this[targetEventId] ?: emptyList()
if (existing.none { it.createdAt == receipt.createdAt && it.senderPubKey == receipt.senderPubKey }) {
this[targetEventId] = existing + receipt
}
}
}
},
)
}
// Subscribe to metadata for zap senders (to show display names)
val zapSenderPubkeys =
zapsByEvent.values
.flatten()
.map { it.senderPubKey }
.distinct()
rememberSubscription(relayStatuses, zapSenderPubkeys, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || zapSenderPubkeys.isEmpty()) {
return@rememberSubscription null
}
// Only fetch metadata for users we don't have yet
val missingPubkeys =
zapSenderPubkeys.filter { pubkey ->
localCache.getUserIfExists(pubkey)?.info == null
}
if (missingPubkeys.isEmpty()) {
return@rememberSubscription null
}
createBatchMetadataSubscription(
relays = configuredRelays,
pubKeyHexList = missingPubkeys,
onEvent = { event, _, _, _ ->
if (event is MetadataEvent) {
localCache.consumeMetadata(event)
}
},
)
}
// Subscribe to reactions for visible events
rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
return@rememberSubscription null
}
createReactionsSubscription(
relays = configuredRelays,
eventIds = eventIds,
onEvent = { event, _, _, _ ->
if (event is ReactionEvent) {
val targetEventId = event.originalPost().firstOrNull() ?: return@createReactionsSubscription
reactionsByEvent =
reactionsByEvent.toMutableMap().apply {
this[targetEventId] = (this[targetEventId] ?: 0) + 1
}
}
},
)
}
// Subscribe to replies for visible events
rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
return@rememberSubscription null
}
createRepliesSubscription(
relays = configuredRelays,
eventIds = eventIds,
onEvent = { event, _, _, _ ->
// Find the event this is replying to
val replyToId =
event.tags
.filter { it.size >= 2 && it[0] == "e" }
.lastOrNull()
?.get(1) ?: return@createRepliesSubscription
if (replyToId in eventIds) {
repliesByEvent =
repliesByEvent.toMutableMap().apply {
this[replyToId] = (this[replyToId] ?: 0) + 1
}
}
},
)
}
// Subscribe to reposts for visible events
rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
return@rememberSubscription null
}
createRepostsSubscription(
relays = configuredRelays,
eventIds = eventIds,
onEvent = { event, _, _, _ ->
if (event is RepostEvent) {
val targetEventId = event.boostedEventId() ?: return@createRepostsSubscription
repostsByEvent =
repostsByEvent.toMutableMap().apply {
this[targetEventId] = (this[targetEventId] ?: 0) + 1
}
}
},
)
}
// Subscribe to metadata for note authors (to enable zaps and populate search cache)
val authorPubkeys = events.map { it.pubKey }.distinct()
rememberSubscription(relayStatuses, authorPubkeys, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || authorPubkeys.isEmpty()) {
return@rememberSubscription null
}
// Only fetch metadata for users we don't have yet
val missingPubkeys =
authorPubkeys.filter { pubkey ->
localCache.getUserIfExists(pubkey)?.info == null
}
if (missingPubkeys.isEmpty()) {
return@rememberSubscription null
}
createBatchMetadataSubscription(
relays = configuredRelays,
pubKeyHexList = missingPubkeys,
onEvent = { event, _, _, _ ->
if (event is MetadataEvent) {
localCache.consumeMetadata(event)
}
},
)
}
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// Header with compose button // Header with compose button
Row( Row(
@@ -217,12 +464,18 @@ fun FeedScreen(
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
FilterChip( FilterChip(
selected = feedMode == FeedMode.GLOBAL, selected = feedMode == FeedMode.GLOBAL,
onClick = { feedMode = FeedMode.GLOBAL }, onClick = {
feedMode = FeedMode.GLOBAL
DesktopPreferences.feedMode = FeedMode.GLOBAL
},
label = { Text("Global") }, label = { Text("Global") },
) )
FilterChip( FilterChip(
selected = feedMode == FeedMode.FOLLOWING, selected = feedMode == FeedMode.FOLLOWING,
onClick = { feedMode = FeedMode.FOLLOWING }, onClick = {
feedMode = FeedMode.FOLLOWING
DesktopPreferences.feedMode = FeedMode.FOLLOWING
},
label = { Text("Following") }, label = { Text("Following") },
) )
} }
@@ -301,10 +554,29 @@ fun FeedScreen(
FeedNoteCard( FeedNoteCard(
event = event, event = event,
relayManager = relayManager, relayManager = relayManager,
localCache = localCache,
account = account, account = account,
nwcConnection = nwcConnection,
onReply = { replyToEvent = event }, onReply = { replyToEvent = event },
onZapFeedback = onZapFeedback,
onNavigateToProfile = onNavigateToProfile, onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread, onNavigateToThread = onNavigateToThread,
zapReceipts = zapsByEvent[event.id] ?: emptyList(),
reactionCount = reactionsByEvent[event.id] ?: 0,
replyCount = repliesByEvent[event.id] ?: 0,
repostCount = repostsByEvent[event.id] ?: 0,
bookmarkList = bookmarkList,
isBookmarked = bookmarkedEventIds.contains(event.id),
onBookmarkChanged = { newList ->
bookmarkList = newList
val pubIds =
newList
.publicBookmarks()
.filterIsInstance<com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark>()
.map { it.eventId }
.toSet()
bookmarkedEventIds = pubIds
},
) )
} }
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,364 @@
/**
* 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.Arrangement
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.filled.Refresh
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.account.AccountState
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
import com.vitorpamplona.amethyst.commons.subscriptions.FeedMode
import com.vitorpamplona.amethyst.commons.subscriptions.createContactListSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createFollowingLongFormFeedSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createLongFormFeedSubscription
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.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private val dateFormat = SimpleDateFormat("MMM d, yyyy", Locale.getDefault())
private fun formatDate(timestamp: Long): String = dateFormat.format(Date(timestamp * 1000))
/**
* Card displaying long-form content (NIP-23) with title, summary, and image.
*/
@Composable
fun LongFormCard(
event: LongTextNoteEvent,
localCache: DesktopLocalCache,
onAuthorClick: (String) -> Unit = {},
onClick: () -> Unit = {},
) {
val author = localCache.getUserIfExists(event.pubKey)
val authorName = author?.info?.bestName() ?: event.pubKey.take(8)
val publishedAt = event.publishedAt() ?: event.createdAt
Card(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
// Title
event.title()?.let { title ->
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(8.dp))
}
// Summary
event.summary()?.let { summary ->
Text(
text = summary,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(12.dp))
}
// Footer with author and date
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = authorName,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable { onAuthorClick(event.pubKey) },
)
Text(
text = formatDate(publishedAt),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Topics/hashtags
val topics = event.topics()
if (topics.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
topics.take(3).forEach { topic ->
Text(
text = "#$topic",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.tertiary,
)
}
}
}
}
}
}
@Composable
fun ReadsScreen(
relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache,
account: AccountState.LoggedIn? = null,
onNavigateToProfile: (String) -> Unit = {},
onNavigateToArticle: (String) -> Unit = {},
) {
val connectedRelays by relayManager.connectedRelays.collectAsState()
val relayStatuses by relayManager.relayStatuses.collectAsState()
val scope = rememberCoroutineScope()
val eventState =
remember {
EventCollectionState<LongTextNoteEvent>(
getId = { it.id },
sortComparator = compareByDescending { it.publishedAt() ?: it.createdAt },
maxSize = 100,
scope = scope,
)
}
val events by eventState.items.collectAsState()
var feedMode by remember { mutableStateOf(FeedMode.GLOBAL) }
var followedUsers by remember { mutableStateOf<Set<String>>(emptySet()) }
var eoseReceivedCount by remember { mutableStateOf(0) }
val initialLoadComplete = eoseReceivedCount > 0
// Load followed users for Following feed mode
rememberSubscription(relayStatuses, account, feedMode, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) {
createContactListSubscription(
relays = configuredRelays,
pubKeyHex = account.pubKeyHex,
onEvent = { event, _, _, _ ->
if (event is ContactListEvent) {
followedUsers = event.verifiedFollowKeySet()
}
},
)
} else {
null
}
}
// Clear events when feed mode changes
remember(feedMode) {
eventState.clear()
eoseReceivedCount = 0
}
// Subscribe to long-form content feed
rememberSubscription(relayStatuses, feedMode, followedUsers, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty()) {
return@rememberSubscription null
}
when (feedMode) {
FeedMode.GLOBAL -> {
createLongFormFeedSubscription(
relays = configuredRelays,
onEvent = { event, _, _, _ ->
if (event is LongTextNoteEvent) {
eventState.addItem(event)
}
},
onEose = { _, _ ->
eoseReceivedCount++
},
)
}
FeedMode.FOLLOWING -> {
if (followedUsers.isNotEmpty()) {
createFollowingLongFormFeedSubscription(
relays = configuredRelays,
followedUsers = followedUsers.toList(),
onEvent = { event, _, _, _ ->
if (event is LongTextNoteEvent) {
eventState.addItem(event)
}
},
onEose = { _, _ ->
eoseReceivedCount++
},
)
} else {
null
}
}
}
}
Column(modifier = Modifier.fillMaxSize()) {
// Header
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"Reads",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground,
)
// Feed mode selector
if (account != null) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
FilterChip(
selected = feedMode == FeedMode.GLOBAL,
onClick = { feedMode = FeedMode.GLOBAL },
label = { Text("Global") },
)
FilterChip(
selected = feedMode == FeedMode.FOLLOWING,
onClick = { feedMode = FeedMode.FOLLOWING },
label = { Text("Following") },
)
}
}
}
Spacer(Modifier.height(4.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"${connectedRelays.size} relays connected",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(8.dp))
IconButton(
onClick = { relayManager.connect() },
modifier = Modifier.size(24.dp),
) {
Icon(
Icons.Default.Refresh,
contentDescription = "Refresh",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
}
}
}
}
Spacer(Modifier.height(8.dp))
when {
connectedRelays.isEmpty() -> {
LoadingState("Connecting to relays...")
}
feedMode == FeedMode.FOLLOWING && followedUsers.isEmpty() -> {
LoadingState("Loading followed users...")
}
events.isEmpty() && !initialLoadComplete -> {
LoadingState("Loading articles...")
}
events.isEmpty() && initialLoadComplete -> {
EmptyState(
title =
if (feedMode == FeedMode.FOLLOWING) {
"No articles from followed users"
} else {
"No articles found"
},
description =
if (feedMode == FeedMode.FOLLOWING) {
"Long-form articles from people you follow will appear here"
} else {
"Long-form articles from the network will appear here"
},
onRefresh = { relayManager.connect() },
)
}
else -> {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(events, key = { it.id }) { event ->
LongFormCard(
event = event,
localCache = localCache,
onAuthorClick = onNavigateToProfile,
onClick = { onNavigateToArticle(event.id) },
)
}
}
}
}
}
}
@@ -0,0 +1,451 @@
/**
* 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.Arrangement
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Tag
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.search.SearchResult
import com.vitorpamplona.amethyst.commons.subscriptions.createMetadataSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createSearchPeopleSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.commons.ui.components.UserSearchCard
import com.vitorpamplona.amethyst.commons.viewmodels.SearchBarState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
@Composable
fun SearchScreen(
localCache: DesktopLocalCache,
relayManager: DesktopRelayConnectionManager,
onNavigateToProfile: (String) -> Unit,
onNavigateToThread: (String) -> Unit,
onNavigateToHashtag: (String) -> Unit = {},
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
val searchState = remember { SearchBarState(localCache, scope) }
val focusRequester = remember { FocusRequester() }
val relayStatuses by relayManager.relayStatuses.collectAsState()
// Collect state from SearchBarState
val searchText by searchState.searchText.collectAsState()
val bech32Results by searchState.bech32Results.collectAsState()
val cachedUserResults by searchState.cachedUserResults.collectAsState()
val relaySearchResults by searchState.relaySearchResults.collectAsState()
val isSearchingRelays by searchState.isSearchingRelays.collectAsState()
// NIP-50 relay search when local cache has few/no results
rememberSubscription(relayStatuses, searchText, cachedUserResults.size, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty()) return@rememberSubscription null
// Only search relays if we have a real query and limited local results
if (searchState.shouldSearchRelays) {
searchState.startRelaySearch()
createSearchPeopleSubscription(
relays = configuredRelays,
searchQuery = searchText,
limit = 20,
onEvent = { event, _, _, _ ->
if (event is MetadataEvent) {
localCache.consumeMetadata(event)
val user = localCache.getUserIfExists(event.pubKey)
if (user != null) {
searchState.addRelaySearchResult(user)
}
}
},
onEose = { _, _ ->
searchState.endRelaySearch()
},
)
} else {
null
}
}
// Subscribe to metadata for searched users (to populate cache)
rememberSubscription(relayStatuses, searchText, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || searchText.length < 2) {
return@rememberSubscription null
}
// If it's a specific pubkey search, fetch that user's metadata
val pubkeyHex = decodePublicKeyAsHexOrNull(searchText)
if (pubkeyHex != null) {
createMetadataSubscription(
relays = configuredRelays,
pubKeyHex = pubkeyHex,
onEvent = { event, _, _, _ ->
if (event is MetadataEvent) {
localCache.consumeMetadata(event)
}
},
)
} else {
null
}
}
// Auto-focus the search field
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
Column(
modifier = modifier.fillMaxSize(),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Search",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground,
)
Text(
"${localCache.userCount()} users cached",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(16.dp))
// Search input field
OutlinedTextField(
value = searchText,
onValueChange = { searchState.updateSearchText(it) },
modifier =
Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
placeholder = { Text("Search by name, npub, nevent, or #hashtag") },
leadingIcon = {
Icon(
Icons.Default.Search,
contentDescription = "Search",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
trailingIcon = {
if (searchText.isNotEmpty()) {
IconButton(onClick = { searchState.clearSearch() }) {
Icon(
Icons.Default.Clear,
contentDescription = "Clear",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
singleLine = true,
shape = RoundedCornerShape(12.dp),
)
Spacer(Modifier.height(16.dp))
// Results
val hasResults = bech32Results.isNotEmpty() || cachedUserResults.isNotEmpty() || relaySearchResults.isNotEmpty()
if (!hasResults && searchText.isNotEmpty() && searchText.length >= 2 && !isSearchingRelays) {
Text(
"No matches found. Try a name, npub, nevent, or #hashtag.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
} else if (isSearchingRelays && !hasResults) {
Text(
"Searching relays...",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
} else if (hasResults) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// Bech32/hex results first
if (bech32Results.isNotEmpty()) {
item {
Text(
"Direct lookup",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 4.dp),
)
}
items(bech32Results) { result ->
SearchResultCard(
result = result,
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
onNavigateToHashtag = onNavigateToHashtag,
)
}
}
// Cached user results
if (cachedUserResults.isNotEmpty()) {
if (bech32Results.isNotEmpty()) {
item {
HorizontalDivider(Modifier.padding(vertical = 8.dp))
}
}
item {
Text(
"Cached users (${cachedUserResults.size})",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 4.dp),
)
}
items(cachedUserResults, key = { "cached-${it.pubkeyHex}" }) { user ->
UserSearchCard(
user = user,
onClick = { onNavigateToProfile(user.pubkeyHex) },
)
}
}
// Relay search results (NIP-50)
if (relaySearchResults.isNotEmpty()) {
if (bech32Results.isNotEmpty() || cachedUserResults.isNotEmpty()) {
item {
HorizontalDivider(Modifier.padding(vertical = 8.dp))
}
}
item {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"From relays (${relaySearchResults.size})",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 4.dp),
)
if (isSearchingRelays) {
Text(
"searching...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
items(relaySearchResults, key = { "relay-${it.pubkeyHex}" }) { user ->
UserSearchCard(
user = user,
onClick = { onNavigateToProfile(user.pubkeyHex) },
)
}
} else if (isSearchingRelays && cachedUserResults.isEmpty()) {
item {
Text(
"Searching relays...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp),
)
}
}
}
} else {
// Empty state
Column(
modifier = Modifier.fillMaxWidth().padding(top = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"Search for users or notes",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
Text(
"Enter a name or Nostr identifier:",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.height(16.dp))
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
SearchHint("vitor", "Search by name")
SearchHint("npub1...", "User profile")
SearchHint("note1...", "Single note")
SearchHint("nevent1...", "Note with metadata")
SearchHint("#hashtag", "Hashtag search")
}
}
}
}
}
@Composable
private fun SearchHint(
identifier: String,
description: String,
) {
Row(
modifier = Modifier.fillMaxWidth(0.6f),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
identifier,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.width(16.dp))
Text(
description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun SearchResultCard(
result: SearchResult,
onNavigateToProfile: (String) -> Unit,
onNavigateToThread: (String) -> Unit,
onNavigateToHashtag: (String) -> Unit,
) {
Card(
modifier =
Modifier
.fillMaxWidth()
.clickable {
when (result) {
is SearchResult.UserResult -> onNavigateToProfile(result.pubKeyHex)
is SearchResult.CachedUserResult -> onNavigateToProfile(result.user.pubkeyHex)
is SearchResult.NoteResult -> onNavigateToThread(result.noteIdHex)
is SearchResult.AddressResult -> {
onNavigateToThread("${result.kind}:${result.pubKeyHex}:${result.dTag}")
}
is SearchResult.HashtagResult -> onNavigateToHashtag(result.hashtag)
}
},
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
imageVector =
when (result) {
is SearchResult.UserResult -> Icons.Default.Person
is SearchResult.CachedUserResult -> Icons.Default.Person
is SearchResult.NoteResult -> Icons.Default.Description
is SearchResult.AddressResult -> Icons.Default.Description
is SearchResult.HashtagResult -> Icons.Default.Tag
},
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary,
)
Column(modifier = Modifier.weight(1f)) {
Text(
when (result) {
is SearchResult.UserResult -> "User Profile"
is SearchResult.CachedUserResult -> result.user.toBestDisplayName()
is SearchResult.NoteResult -> "Note"
is SearchResult.AddressResult -> "Event (kind ${result.kind})"
is SearchResult.HashtagResult -> "#${result.hashtag}"
},
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
when (result) {
is SearchResult.UserResult -> result.displayId
is SearchResult.CachedUserResult -> result.user.pubkeyDisplayHex()
is SearchResult.NoteResult -> result.displayId
is SearchResult.AddressResult -> result.displayId
is SearchResult.HashtagResult -> "Search posts with this hashtag"
},
style = MaterialTheme.typography.bodySmall,
fontFamily = if (result is SearchResult.HashtagResult) null else FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Icon(
Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = "Navigate",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -52,16 +52,28 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.account.AccountState import com.vitorpamplona.amethyst.commons.account.AccountState
import com.vitorpamplona.amethyst.commons.state.EventCollectionState import com.vitorpamplona.amethyst.commons.state.EventCollectionState
import com.vitorpamplona.amethyst.commons.subscriptions.FilterBuilders
import com.vitorpamplona.amethyst.commons.subscriptions.SubscriptionConfig
import com.vitorpamplona.amethyst.commons.subscriptions.createNoteSubscription import com.vitorpamplona.amethyst.commons.subscriptions.createNoteSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createReactionsSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createRepliesSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createRepostsSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createThreadRepliesSubscription import com.vitorpamplona.amethyst.commons.subscriptions.createThreadRepliesSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createZapsSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.note.NoteCard import com.vitorpamplona.amethyst.commons.ui.note.NoteCard
import com.vitorpamplona.amethyst.commons.ui.thread.drawReplyLevel import com.vitorpamplona.amethyst.commons.ui.thread.drawReplyLevel
import com.vitorpamplona.amethyst.commons.util.toNoteDisplayData import com.vitorpamplona.amethyst.commons.util.toNoteDisplayData
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
/** /**
* Desktop Thread Screen - displays a note and all its replies in a thread view. * Desktop Thread Screen - displays a note and all its replies in a thread view.
@@ -72,10 +84,14 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
fun ThreadScreen( fun ThreadScreen(
noteId: String, noteId: String,
relayManager: DesktopRelayConnectionManager, relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache,
account: AccountState.LoggedIn?, account: AccountState.LoggedIn?,
nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
onBack: () -> Unit, onBack: () -> Unit,
onNavigateToProfile: (String) -> Unit = {}, onNavigateToProfile: (String) -> Unit = {},
onNavigateToThread: (String) -> Unit = {}, onNavigateToThread: (String) -> Unit = {},
onZapFeedback: (ZapFeedback) -> Unit = {},
onReply: (Event) -> Unit = {},
) { ) {
val connectedRelays by relayManager.connectedRelays.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState()
val relayStatuses by relayManager.relayStatuses.collectAsState() val relayStatuses by relayManager.relayStatuses.collectAsState()
@@ -103,6 +119,50 @@ fun ThreadScreen(
var rootNoteEoseReceived by remember(noteId) { mutableStateOf(false) } var rootNoteEoseReceived by remember(noteId) { mutableStateOf(false) }
var repliesEoseReceived by remember(noteId) { mutableStateOf(false) } var repliesEoseReceived by remember(noteId) { mutableStateOf(false) }
// Track zaps per event
var zapsByEvent by remember(noteId) { mutableStateOf<Map<String, List<ZapReceipt>>>(emptyMap()) }
var reactionsByEvent by remember(noteId) { mutableStateOf<Map<String, Int>>(emptyMap()) }
var repliesByEvent by remember(noteId) { mutableStateOf<Map<String, Int>>(emptyMap()) }
var repostsByEvent by remember(noteId) { mutableStateOf<Map<String, Int>>(emptyMap()) }
// Bookmark state
var bookmarkList by remember { mutableStateOf<BookmarkListEvent?>(null) }
var bookmarkedEventIds by remember { mutableStateOf<Set<String>>(emptySet()) }
// Subscribe to user's bookmark list
rememberSubscription(relayStatuses, account, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty() && account != null) {
SubscriptionConfig(
subId = "thread-bookmarks-${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
val pubIds =
event
.publicBookmarks()
.filterIsInstance<EventBookmark>()
.map { it.eventId }
.toSet()
bookmarkedEventIds = pubIds
}
},
onEose = { _, _ -> },
)
} else {
null
}
}
// Subscribe to the root note // Subscribe to the root note
rememberSubscription(relayStatuses, noteId, relayManager = relayManager) { rememberSubscription(relayStatuses, noteId, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys val configuredRelays = relayStatuses.keys
@@ -144,6 +204,103 @@ fun ThreadScreen(
} }
} }
// Subscribe to zaps for thread events
val allEventIds = listOf(noteId) + replyEvents.map { it.id }
rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || allEventIds.isEmpty()) {
return@rememberSubscription null
}
createZapsSubscription(
relays = configuredRelays,
eventIds = allEventIds,
onEvent = { event, _, _, _ ->
if (event is LnZapEvent) {
val receipt = event.toZapReceipt(localCache) ?: return@createZapsSubscription
val targetEventId = event.zappedPost().firstOrNull() ?: return@createZapsSubscription
zapsByEvent =
zapsByEvent.toMutableMap().apply {
val existing = this[targetEventId] ?: emptyList()
if (existing.none { it.createdAt == receipt.createdAt && it.senderPubKey == receipt.senderPubKey }) {
this[targetEventId] = existing + receipt
}
}
}
},
)
}
// Subscribe to reactions for thread events
rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || allEventIds.isEmpty()) {
return@rememberSubscription null
}
createReactionsSubscription(
relays = configuredRelays,
eventIds = allEventIds,
onEvent = { event, _, _, _ ->
if (event is ReactionEvent) {
val targetEventId = event.originalPost().firstOrNull() ?: return@createReactionsSubscription
reactionsByEvent =
reactionsByEvent.toMutableMap().apply {
this[targetEventId] = (this[targetEventId] ?: 0) + 1
}
}
},
)
}
// Subscribe to replies for thread events (for counts)
rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || allEventIds.isEmpty()) {
return@rememberSubscription null
}
createRepliesSubscription(
relays = configuredRelays,
eventIds = allEventIds,
onEvent = { event, _, _, _ ->
val replyToId =
event.tags
.filter { it.size >= 2 && it[0] == "e" }
.lastOrNull()
?.get(1) ?: return@createRepliesSubscription
if (replyToId in allEventIds) {
repliesByEvent =
repliesByEvent.toMutableMap().apply {
this[replyToId] = (this[replyToId] ?: 0) + 1
}
}
},
)
}
// Subscribe to reposts for thread events
rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || allEventIds.isEmpty()) {
return@rememberSubscription null
}
createRepostsSubscription(
relays = configuredRelays,
eventIds = allEventIds,
onEvent = { event, _, _, _ ->
if (event is RepostEvent) {
val targetEventId = event.boostedEventId() ?: return@createRepostsSubscription
repostsByEvent =
repostsByEvent.toMutableMap().apply {
this[targetEventId] = (this[targetEventId] ?: 0) + 1
}
}
},
)
}
// Calculate reply level for an event based on e-tags // Calculate reply level for an event based on e-tags
fun calculateLevel(event: Event): Int { fun calculateLevel(event: Event): Int {
levelCache[event.id]?.let { return it } levelCache[event.id]?.let { return it }
@@ -205,16 +362,38 @@ fun ThreadScreen(
}, },
) { ) {
NoteCard( NoteCard(
note = rootNote!!.toNoteDisplayData(), note = rootNote!!.toNoteDisplayData(localCache),
onAuthorClick = onNavigateToProfile, onAuthorClick = onNavigateToProfile,
) )
if (account != null) { if (account != null) {
val rootZaps = zapsByEvent[noteId] ?: emptyList()
NoteActionsRow( NoteActionsRow(
event = rootNote!!, event = rootNote!!,
relayManager = relayManager, relayManager = relayManager,
localCache = localCache,
account = account, account = account,
onReplyClick = { /* TODO: Open reply dialog */ }, nwcConnection = nwcConnection,
onReplyClick = { onReply(rootNote!!) },
onZapFeedback = onZapFeedback,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
zapCount = rootZaps.size,
zapAmountSats = rootZaps.sumOf { it.amountSats },
zapReceipts = rootZaps,
reactionCount = reactionsByEvent[noteId] ?: 0,
replyCount = repliesByEvent[noteId] ?: 0,
repostCount = repostsByEvent[noteId] ?: 0,
bookmarkList = bookmarkList,
isBookmarked = bookmarkedEventIds.contains(noteId),
onBookmarkChanged = { newList ->
bookmarkList = newList
val pubIds =
newList
.publicBookmarks()
.filterIsInstance<EventBookmark>()
.map { it.eventId }
.toSet()
bookmarkedEventIds = pubIds
},
) )
} }
} }
@@ -242,16 +421,38 @@ fun ThreadScreen(
}, },
) { ) {
NoteCard( NoteCard(
note = event.toNoteDisplayData(), note = event.toNoteDisplayData(localCache),
onAuthorClick = onNavigateToProfile, onAuthorClick = onNavigateToProfile,
) )
if (account != null) { if (account != null) {
val eventZaps = zapsByEvent[event.id] ?: emptyList()
NoteActionsRow( NoteActionsRow(
event = event, event = event,
relayManager = relayManager, relayManager = relayManager,
localCache = localCache,
account = account, account = account,
onReplyClick = { /* TODO: Open reply dialog */ }, nwcConnection = nwcConnection,
onReplyClick = { onReply(event) },
onZapFeedback = onZapFeedback,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
zapCount = eventZaps.size,
zapAmountSats = eventZaps.sumOf { it.amountSats },
zapReceipts = eventZaps,
reactionCount = reactionsByEvent[event.id] ?: 0,
replyCount = repliesByEvent[event.id] ?: 0,
repostCount = repostsByEvent[event.id] ?: 0,
bookmarkList = bookmarkList,
isBookmarked = bookmarkedEventIds.contains(event.id),
onBookmarkChanged = { newList ->
bookmarkList = newList
val pubIds =
newList
.publicBookmarks()
.filterIsInstance<EventBookmark>()
.map { it.eventId }
.toSet()
bookmarkedEventIds = pubIds
},
) )
} }
} }
@@ -35,6 +35,8 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.PersonRemove import androidx.compose.material.icons.filled.PersonRemove
import androidx.compose.material3.Button import androidx.compose.material3.Button
@@ -46,6 +48,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -60,20 +63,26 @@ import com.vitorpamplona.amethyst.commons.account.AccountState
import com.vitorpamplona.amethyst.commons.model.nip02FollowList.FollowAction import com.vitorpamplona.amethyst.commons.model.nip02FollowList.FollowAction
import com.vitorpamplona.amethyst.commons.state.EventCollectionState import com.vitorpamplona.amethyst.commons.state.EventCollectionState
import com.vitorpamplona.amethyst.commons.state.FollowState import com.vitorpamplona.amethyst.commons.state.FollowState
import com.vitorpamplona.amethyst.commons.subscriptions.FilterBuilders
import com.vitorpamplona.amethyst.commons.subscriptions.SubscriptionConfig
import com.vitorpamplona.amethyst.commons.subscriptions.createContactListSubscription import com.vitorpamplona.amethyst.commons.subscriptions.createContactListSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createMetadataSubscription import com.vitorpamplona.amethyst.commons.subscriptions.createMetadataSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createUserPostsSubscription import com.vitorpamplona.amethyst.commons.subscriptions.createUserPostsSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip19Bech32.toNpub
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.awt.Toolkit
import java.awt.datatransfer.StringSelection
/** /**
* User profile screen showing user info, follow button, and their posts. * User profile screen showing user info, follow button, and their posts.
@@ -82,10 +91,13 @@ import kotlinx.coroutines.withContext
fun UserProfileScreen( fun UserProfileScreen(
pubKeyHex: String, pubKeyHex: String,
relayManager: DesktopRelayConnectionManager, relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache,
account: AccountState.LoggedIn?, account: AccountState.LoggedIn?,
nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
onBack: () -> Unit, onBack: () -> Unit,
onCompose: () -> Unit = {}, onCompose: () -> Unit = {},
onNavigateToProfile: (String) -> Unit = {}, onNavigateToProfile: (String) -> Unit = {},
onZapFeedback: (ZapFeedback) -> Unit = {},
) { ) {
val connectedRelays by relayManager.connectedRelays.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState()
val relayStatuses by relayManager.relayStatuses.collectAsState() val relayStatuses by relayManager.relayStatuses.collectAsState()
@@ -188,6 +200,61 @@ fun UserProfileScreen(
} }
} }
// Subscribe to profile user's contact list (for following count)
rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty()) {
createContactListSubscription(
relays = configuredRelays,
pubKeyHex = pubKeyHex,
onEvent = { event, _, _, _ ->
if (event is ContactListEvent) {
// Count the number of people this user follows
followingCount = event.verifiedFollowKeySet().size
}
},
onEose = { _, _ -> },
)
} else {
null
}
}
// Track unique followers (authors of contact lists that tag this pubkey)
val followerAuthors = remember(pubKeyHex) { mutableSetOf<String>() }
// Subscribe to followers (contact lists that tag this user)
rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty()) {
// Clear previous followers when subscription restarts
followerAuthors.clear()
followersCount = 0
SubscriptionConfig(
subId = "followers-${pubKeyHex.take(8)}-${System.currentTimeMillis()}",
filters =
listOf(
FilterBuilders.byPTags(
pubKeys = listOf(pubKeyHex),
kinds = listOf(3), // ContactListEvent
limit = 500,
),
),
relays = configuredRelays,
onEvent = { event, _, _, _ ->
// Count unique authors who follow this user
if (followerAuthors.add(event.pubKey)) {
followersCount = followerAuthors.size
}
},
onEose = { _, _ -> },
)
} else {
null
}
}
// Subscribe to user posts // Subscribe to user posts
rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) { rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys val configuredRelays = relayStatuses.keys
@@ -339,11 +406,49 @@ fun UserProfileScreen(
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Text( val npub = pubKeyHex.hexToByteArrayOrNull()?.toNpub()
(pubKeyHex.hexToByteArrayOrNull()?.toNpub()?.take(32) ?: pubKeyHex.take(32)) + "...", var copied by remember { mutableStateOf(false) }
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, // Reset copied state after delay
) LaunchedEffect(copied) {
if (copied) {
delay(2000)
copied = false
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
(npub?.take(32) ?: pubKeyHex.take(32)) + "...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (npub != null) {
IconButton(
onClick = {
val clipboard = Toolkit.getDefaultToolkit().systemClipboard
clipboard.setContents(StringSelection(npub), null)
copied = true
},
modifier = Modifier.size(20.dp),
) {
Icon(
if (copied) Icons.Default.Check else Icons.Default.ContentCopy,
contentDescription = if (copied) "Copied" else "Copy npub",
modifier = Modifier.size(14.dp),
tint =
if (copied) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}
} }
} }
@@ -461,8 +566,11 @@ fun UserProfileScreen(
FeedNoteCard( FeedNoteCard(
event = event, event = event,
relayManager = relayManager, relayManager = relayManager,
localCache = localCache,
account = account, account = account,
nwcConnection = nwcConnection,
onReply = onCompose, onReply = onCompose,
onZapFeedback = onZapFeedback,
onNavigateToProfile = onNavigateToProfile, onNavigateToProfile = onNavigateToProfile,
) )
} }