zap updates
This commit is contained in:
@@ -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 }
|
||||||
Vendored
+22
@@ -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?
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+127
@@ -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
|
||||||
|
}
|
||||||
+69
@@ -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()
|
||||||
|
}
|
||||||
+3
-2
@@ -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
|
||||||
|
|||||||
+5
-2
@@ -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) {
|
||||||
|
|||||||
+105
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
@@ -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()
|
||||||
|
|||||||
+149
@@ -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)
|
||||||
|
}
|
||||||
+163
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
@@ -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,
|
||||||
|
|||||||
+224
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+97
@@ -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
|
||||||
|
}
|
||||||
+184
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
+161
@@ -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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+32
-2
@@ -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).
|
||||||
|
|||||||
+6
-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,
|
||||||
)
|
)
|
||||||
|
|||||||
+57
@@ -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,10 +320,31 @@ 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,
|
||||||
) {
|
) {
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val onZapFeedback: (ZapFeedback) -> Unit = { feedback ->
|
||||||
|
scope.launch {
|
||||||
|
val message =
|
||||||
|
when (feedback) {
|
||||||
|
is ZapFeedback.Success -> "Zapped ${feedback.amountSats} sats"
|
||||||
|
is ZapFeedback.ExternalWallet -> "Invoice sent to wallet (${feedback.amountSats} sats)"
|
||||||
|
is ZapFeedback.Error -> "Zap failed: ${feedback.message}"
|
||||||
|
is ZapFeedback.Timeout -> "Zap timed out"
|
||||||
|
is ZapFeedback.NoLightningAddress -> "User has no lightning address"
|
||||||
|
}
|
||||||
|
snackbarHostState.showSnackbar(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
Row(Modifier.fillMaxSize()) {
|
Row(Modifier.fillMaxSize()) {
|
||||||
// Sidebar Navigation
|
// Sidebar Navigation
|
||||||
NavigationRail(
|
NavigationRail(
|
||||||
@@ -299,6 +360,13 @@ fun MainContent(
|
|||||||
onClick = { onScreenChange(DesktopScreen.Feed) },
|
onClick = { onScreenChange(DesktopScreen.Feed) },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
NavigationRailItem(
|
||||||
|
icon = { Icon(Icons.AutoMirrored.Filled.Article, contentDescription = "Reads") },
|
||||||
|
label = { Text("Reads") },
|
||||||
|
selected = currentScreen == DesktopScreen.Reads,
|
||||||
|
onClick = { onScreenChange(DesktopScreen.Reads) },
|
||||||
|
)
|
||||||
|
|
||||||
NavigationRailItem(
|
NavigationRailItem(
|
||||||
icon = { Icon(Icons.Default.Search, contentDescription = "Search") },
|
icon = { Icon(Icons.Default.Search, contentDescription = "Search") },
|
||||||
label = { Text("Search") },
|
label = { Text("Search") },
|
||||||
@@ -306,6 +374,13 @@ fun MainContent(
|
|||||||
onClick = { onScreenChange(DesktopScreen.Search) },
|
onClick = { onScreenChange(DesktopScreen.Search) },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
NavigationRailItem(
|
||||||
|
icon = { Icon(com.vitorpamplona.amethyst.commons.icons.Bookmark, contentDescription = "Bookmarks") },
|
||||||
|
label = { Text("Bookmarks") },
|
||||||
|
selected = currentScreen == DesktopScreen.Bookmarks,
|
||||||
|
onClick = { onScreenChange(DesktopScreen.Bookmarks) },
|
||||||
|
)
|
||||||
|
|
||||||
NavigationRailItem(
|
NavigationRailItem(
|
||||||
icon = { Icon(Icons.Default.Email, contentDescription = "Messages") },
|
icon = { Icon(Icons.Default.Email, contentDescription = "Messages") },
|
||||||
label = { Text("DMs") },
|
label = { Text("DMs") },
|
||||||
@@ -351,7 +426,9 @@ fun MainContent(
|
|||||||
DesktopScreen.Feed ->
|
DesktopScreen.Feed ->
|
||||||
FeedScreen(
|
FeedScreen(
|
||||||
relayManager = relayManager,
|
relayManager = relayManager,
|
||||||
|
localCache = localCache,
|
||||||
account = account,
|
account = account,
|
||||||
|
nwcConnection = nwcConnection,
|
||||||
onCompose = onShowComposeDialog,
|
onCompose = onShowComposeDialog,
|
||||||
onNavigateToProfile = { pubKeyHex ->
|
onNavigateToProfile = { pubKeyHex ->
|
||||||
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
|
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
|
||||||
@@ -359,37 +436,82 @@ fun MainContent(
|
|||||||
onNavigateToThread = { noteId ->
|
onNavigateToThread = { noteId ->
|
||||||
onScreenChange(DesktopScreen.Thread(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.Search -> SearchPlaceholder()
|
|
||||||
DesktopScreen.Messages -> MessagesPlaceholder()
|
DesktopScreen.Messages -> MessagesPlaceholder()
|
||||||
DesktopScreen.Notifications -> NotificationsScreen(relayManager, account)
|
DesktopScreen.Notifications -> NotificationsScreen(relayManager, account)
|
||||||
DesktopScreen.MyProfile ->
|
DesktopScreen.MyProfile ->
|
||||||
UserProfileScreen(
|
UserProfileScreen(
|
||||||
pubKeyHex = account.pubKeyHex,
|
pubKeyHex = account.pubKeyHex,
|
||||||
relayManager = relayManager,
|
relayManager = relayManager,
|
||||||
|
localCache = localCache,
|
||||||
account = account,
|
account = account,
|
||||||
|
nwcConnection = nwcConnection,
|
||||||
onBack = { onScreenChange(DesktopScreen.Feed) },
|
onBack = { onScreenChange(DesktopScreen.Feed) },
|
||||||
onCompose = onShowComposeDialog,
|
onCompose = onShowComposeDialog,
|
||||||
onNavigateToProfile = { pubKeyHex ->
|
onNavigateToProfile = { pubKeyHex ->
|
||||||
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
|
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
|
||||||
},
|
},
|
||||||
|
onZapFeedback = onZapFeedback,
|
||||||
)
|
)
|
||||||
is DesktopScreen.UserProfile ->
|
is DesktopScreen.UserProfile ->
|
||||||
UserProfileScreen(
|
UserProfileScreen(
|
||||||
pubKeyHex = currentScreen.pubKeyHex,
|
pubKeyHex = currentScreen.pubKeyHex,
|
||||||
relayManager = relayManager,
|
relayManager = relayManager,
|
||||||
|
localCache = localCache,
|
||||||
account = account,
|
account = account,
|
||||||
|
nwcConnection = nwcConnection,
|
||||||
onBack = { onScreenChange(DesktopScreen.Feed) },
|
onBack = { onScreenChange(DesktopScreen.Feed) },
|
||||||
onCompose = onShowComposeDialog,
|
onCompose = onShowComposeDialog,
|
||||||
onNavigateToProfile = { pubKeyHex ->
|
onNavigateToProfile = { pubKeyHex ->
|
||||||
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
|
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
|
||||||
},
|
},
|
||||||
|
onZapFeedback = onZapFeedback,
|
||||||
)
|
)
|
||||||
is DesktopScreen.Thread ->
|
is DesktopScreen.Thread ->
|
||||||
ThreadScreen(
|
ThreadScreen(
|
||||||
noteId = currentScreen.noteId,
|
noteId = currentScreen.noteId,
|
||||||
relayManager = relayManager,
|
relayManager = relayManager,
|
||||||
|
localCache = localCache,
|
||||||
account = account,
|
account = account,
|
||||||
|
nwcConnection = nwcConnection,
|
||||||
onBack = { onScreenChange(DesktopScreen.Feed) },
|
onBack = { onScreenChange(DesktopScreen.Feed) },
|
||||||
onNavigateToProfile = { pubKeyHex ->
|
onNavigateToProfile = { pubKeyHex ->
|
||||||
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
|
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
|
||||||
@@ -397,11 +519,20 @@ fun MainContent(
|
|||||||
onNavigateToThread = { noteId ->
|
onNavigateToThread = { noteId ->
|
||||||
onScreenChange(DesktopScreen.Thread(noteId))
|
onScreenChange(DesktopScreen.Thread(noteId))
|
||||||
},
|
},
|
||||||
|
onZapFeedback = onZapFeedback,
|
||||||
|
onReply = onShowReplyDialog,
|
||||||
)
|
)
|
||||||
DesktopScreen.Settings -> RelaySettingsScreen(relayManager, account)
|
DesktopScreen.Settings -> RelaySettingsScreen(relayManager, account, accountManager)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Snackbar for zap feedback
|
||||||
|
SnackbarHost(
|
||||||
|
hostState = snackbarHostState,
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -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
|
||||||
|
|||||||
Vendored
+262
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+181
@@ -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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+336
@@ -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
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+929
-14
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+205
-4
@@ -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
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+109
-1
@@ -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))
|
||||||
|
val npub = pubKeyHex.hexToByteArrayOrNull()?.toNpub()
|
||||||
|
var copied by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Reset copied state after delay
|
||||||
|
LaunchedEffect(copied) {
|
||||||
|
if (copied) {
|
||||||
|
delay(2000)
|
||||||
|
copied = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
(pubKeyHex.hexToByteArrayOrNull()?.toNpub()?.take(32) ?: pubKeyHex.take(32)) + "...",
|
(npub?.take(32) ?: pubKeyHex.take(32)) + "...",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user