Merge pull request #1676 from nrobi144/nrobi144/phase-2A

Desktop: Basic Search + Bookmarks + Zaps
This commit is contained in:
Vitor Pamplona
2026-01-26 09:53:35 -05:00
committed by GitHub
106 changed files with 6907 additions and 322 deletions
@@ -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 }
@@ -112,5 +112,27 @@ interface ICacheProvider {
*/
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?
fun justConsumeMyOwnEvent(event: Event): Boolean
}
@@ -0,0 +1,221 @@
/**
* 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.relayClient.assemblers
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataPreloader
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.PrioritizedSubscriptionQueue
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubscriptionPriority
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import kotlinx.coroutines.CoroutineScope
/**
* Coordinates metadata and reactions loading for feed items.
* Ensures metadata (display names, avatars) loads before reactions.
*
* Priority order:
* 1. METADATA - Display names, avatars (highest priority)
* 2. REACTIONS - Likes, zaps, reposts (second priority)
*
* Usage:
* ```
* val coordinator = FeedMetadataCoordinator(client, scope, indexRelays, preloader)
* coordinator.start()
*
* // When feed loads new notes
* LaunchedEffect(notes) {
* coordinator.loadMetadataForNotes(notes)
* }
* ```
*/
class FeedMetadataCoordinator(
private val client: INostrClient,
private val scope: CoroutineScope,
private val indexRelays: Set<NormalizedRelayUrl>,
private val preloader: MetadataPreloader? = null,
private val onEvent: ((Event, NormalizedRelayUrl) -> Unit)? = null,
) {
private val priorityQueue = PrioritizedSubscriptionQueue(scope)
// Track what we've already queued to avoid duplicates
private val queuedPubkeys = mutableSetOf<HexKey>()
private val queuedNoteIds = mutableSetOf<HexKey>()
/**
* Start processing the subscription queue.
* Call once when coordinator is created.
*/
fun start() {
priorityQueue.start { filter ->
// Convert filter to relay-based map for all index relays
val filterMap = indexRelays.associateWith { listOf(filter) }
// Create listener to pass events to the callback
val listener =
if (onEvent != null) {
object : IRequestListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
onEvent.invoke(event, relay)
}
}
} else {
null
}
client.openReqSubscription(
subId = newSubId(),
filters = filterMap,
listener = listener,
)
}
}
/**
* Load metadata and reactions for a list of notes.
* Metadata loads first (priority 1), then reactions (priority 2).
*
* @param notes The notes to load metadata/reactions for
*/
fun loadMetadataForNotes(notes: List<Note>) {
if (notes.isEmpty()) return
// Extract unique authors that we haven't already queued
val authors =
notes
.mapNotNull { it.author?.pubkeyHex }
.filter { it !in queuedPubkeys }
.distinct()
// Extract unique note IDs that we haven't already queued
val noteIds =
notes
.map { it.idHex }
.filter { it !in queuedNoteIds }
.distinct()
// Queue metadata first (highest priority)
if (authors.isNotEmpty()) {
queuedPubkeys.addAll(authors)
// Use preloader if available for rate-limited loading
if (preloader != null) {
notes.mapNotNull { it.author }.forEach { user ->
preloader.preloadForUser(user)
}
} else {
// Direct queue without rate limiting
val metadataFilter =
Filter(
kinds = listOf(MetadataEvent.KIND),
authors = authors,
limit = authors.size,
)
priorityQueue.enqueue(
SubscriptionPriority.METADATA,
metadataFilter,
tag = "feed-metadata",
)
}
}
// Queue reactions second (lower priority)
if (noteIds.isNotEmpty()) {
queuedNoteIds.addAll(noteIds)
val reactionsFilter =
Filter(
kinds = listOf(ReactionEvent.KIND),
tags = mapOf("e" to noteIds),
)
priorityQueue.enqueue(
SubscriptionPriority.REACTIONS,
reactionsFilter,
tag = "feed-reactions",
)
}
}
/**
* Load metadata for specific pubkeys.
* Useful for loading follower/following metadata.
*/
fun loadMetadataForPubkeys(pubkeys: List<HexKey>) {
val newPubkeys = pubkeys.filter { it !in queuedPubkeys }
if (newPubkeys.isEmpty()) return
queuedPubkeys.addAll(newPubkeys)
val filter =
Filter(
kinds = listOf(MetadataEvent.KIND),
authors = newPubkeys,
limit = newPubkeys.size,
)
priorityQueue.enqueue(
SubscriptionPriority.METADATA,
filter,
tag = "pubkey-metadata",
)
}
/**
* Load reactions for specific note IDs.
*/
fun loadReactionsForNotes(noteIds: List<HexKey>) {
val newNoteIds = noteIds.filter { it !in queuedNoteIds }
if (newNoteIds.isEmpty()) return
queuedNoteIds.addAll(newNoteIds)
val filter =
Filter(
kinds = listOf(ReactionEvent.KIND),
tags = mapOf("e" to newNoteIds),
)
priorityQueue.enqueue(
SubscriptionPriority.REACTIONS,
filter,
tag = "note-reactions",
)
}
/**
* Clear queued items. Call when switching feeds.
*/
fun clear() {
priorityQueue.clear()
queuedPubkeys.clear()
queuedNoteIds.clear()
}
}
@@ -0,0 +1,93 @@
/**
* 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.relayClient.assemblers
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager
import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* Query state for metadata subscriptions.
* Groups pubkeys with their preferred index relays.
*/
data class MetadataQueryState(
val pubkeys: Set<HexKey>,
val indexRelays: Set<NormalizedRelayUrl>,
)
/**
* Subscribes to Kind 0 (user metadata) for a set of pubkeys.
* Used to load display names, avatars, and other profile information.
*
* This assembler:
* - Batches multiple pubkey requests into single subscription
* - Sends requests to index relays for efficient discovery
* - Caches EOSE to avoid re-fetching known metadata
*/
class MetadataFilterAssembler(
client: INostrClient,
allKeys: () -> Set<MetadataQueryState>,
) : SingleSubEoseManager<MetadataQueryState>(client, allKeys, invalidateAfterEose = true) {
override fun distinct(key: MetadataQueryState): Any = key.pubkeys.hashCode()
override fun updateFilter(
keys: List<MetadataQueryState>,
since: SincePerRelayMap?,
): List<RelayBasedFilter>? {
// Collect all pubkeys and relays from all query states
val allPubkeys = mutableSetOf<HexKey>()
val allRelays = mutableSetOf<NormalizedRelayUrl>()
keys.forEach { state ->
allPubkeys.addAll(state.pubkeys)
allRelays.addAll(state.indexRelays)
}
if (allPubkeys.isEmpty() || allRelays.isEmpty()) return null
val pubkeyList = allPubkeys.toList()
// Create filter for metadata (Kind 0)
val filter =
Filter(
kinds = listOf(MetadataEvent.KIND),
authors = pubkeyList,
limit = pubkeyList.size,
)
// Apply since times per relay
return allRelays.map { relay ->
val sinceTime = since?.get(relay)?.time
val filterWithSince =
if (sinceTime != null) {
filter.copy(since = sinceTime)
} else {
filter
}
RelayBasedFilter(relay, filterWithSince)
}
}
}
@@ -0,0 +1,92 @@
/**
* 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.relayClient.assemblers
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager
import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
/**
* Query state for reactions subscriptions.
* Groups note IDs with their preferred relays.
*/
data class ReactionsQueryState(
val noteIds: Set<HexKey>,
val relays: Set<NormalizedRelayUrl>,
)
/**
* Subscribes to Kind 7 (reactions) for a set of note IDs.
* Used to load like counts, zap counts, and other reactions.
*
* This assembler:
* - Batches multiple note ID requests into single subscription
* - Uses e-tags to filter reactions for specific notes
* - Caches EOSE to avoid re-fetching known reactions
*/
class ReactionsFilterAssembler(
client: INostrClient,
allKeys: () -> Set<ReactionsQueryState>,
) : SingleSubEoseManager<ReactionsQueryState>(client, allKeys, invalidateAfterEose = true) {
override fun distinct(key: ReactionsQueryState): Any = key.noteIds.hashCode()
override fun updateFilter(
keys: List<ReactionsQueryState>,
since: SincePerRelayMap?,
): List<RelayBasedFilter>? {
// Collect all note IDs and relays from all query states
val allNoteIds = mutableSetOf<HexKey>()
val allRelays = mutableSetOf<NormalizedRelayUrl>()
keys.forEach { state ->
allNoteIds.addAll(state.noteIds)
allRelays.addAll(state.relays)
}
if (allNoteIds.isEmpty() || allRelays.isEmpty()) return null
val noteIdList = allNoteIds.toList()
// Create filter for reactions (Kind 7) targeting these notes via e-tags
val filter =
Filter(
kinds = listOf(ReactionEvent.KIND),
tags = mapOf("e" to noteIdList),
)
// Apply since times per relay
return allRelays.map { relay ->
val sinceTime = since?.get(relay)?.time
val filterWithSince =
if (sinceTime != null) {
filter.copy(since = sinceTime)
} else {
filter
}
RelayBasedFilter(relay, filterWithSince)
}
}
}
@@ -0,0 +1,52 @@
/**
* 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.relayClient.composeSubscriptionManagers
import java.util.concurrent.ConcurrentHashMap
/**
* This allows composables to directly register their queries
* to relays. There may be multiple duplications in these
* subscriptions since we do not control when screens are removed.
*/
abstract class ComposeSubscriptionManager<T> : ComposeSubscriptionManagerControls {
private var composeSubscriptions: ConcurrentHashMap<T, T> = ConcurrentHashMap()
// This is called by main. Keep it really fast.
fun subscribe(query: T?) {
if (query == null) return
composeSubscriptions.put(query, query)
invalidateKeys()
}
// This is called by main. Keep it really fast.
fun unsubscribe(query: T?) {
if (query == null) return
composeSubscriptions.remove(query)
invalidateKeys()
}
fun allKeys() = composeSubscriptions.keys
}
@@ -0,0 +1,29 @@
/**
* 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.relayClient.composeSubscriptionManagers
interface ComposeSubscriptionManagerControls {
fun invalidateKeys()
fun invalidateFilters()
fun destroy()
}
@@ -0,0 +1,78 @@
/**
* 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.relayClient.composeSubscriptionManagers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
/**
* This allows composables to directly register their queries
* to relays. There may be multiple duplications in these
* subscriptions since we do not control when screens are removed.
*
* This is similar to QueryBasedSubscriptionOrchestrator, but it
* also allows the subscription itself to change over time as a
* flow, which trigger an update on the relay subscriptions
*/
abstract class MutableComposeSubscriptionManager<T : MutableQueryState>(
val scope: CoroutineScope,
) : ComposeSubscriptionManagerControls {
private var composeSubscriptions: ConcurrentHashMap<T, Job?> = ConcurrentHashMap()
// This is called by main. Keep it really fast.
fun subscribe(query: T?) {
if (query == null) return
composeSubscriptions[query]?.cancel()
composeSubscriptions[query] =
scope.launch {
query.flow().collectLatest {
invalidateKeys()
}
}
invalidateKeys()
}
// This is called by main. Keep it really fast.
fun unsubscribe(query: T?) {
if (query == null) return
composeSubscriptions[query]?.cancel()
composeSubscriptions.remove(query)
invalidateKeys()
}
fun allKeys() = composeSubscriptions.keys
fun forEachSubscriber(action: (T) -> Unit) {
composeSubscriptions.keys.forEach(action)
}
}
interface MutableQueryState {
fun flow(): Flow<*>
}
@@ -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.relayClient.eoseManagers
import com.vitorpamplona.amethyst.commons.service.BundledUpdate
import com.vitorpamplona.amethyst.commons.utils.isDebug
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.SubscriptionController
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
abstract class BaseEoseManager<T>(
val client: INostrClient,
val allKeys: () -> Set<T>,
val sampleTime: Long = 500,
) : IEoseManager {
protected val logTag: String = this::class.simpleName ?: "BaseEoseManager"
private val orchestrator = SubscriptionController(client)
abstract fun updateSubscriptions(keys: Set<T>)
fun newSubscriptionId() = if (isDebug) logTag + newSubId() else newSubId()
fun getSubscription(subId: String) = orchestrator.getSub(subId)
fun requestNewSubscription(listener: IRequestListener) = orchestrator.requestNewSubscription(newSubscriptionId(), listener)
fun dismissSubscription(subId: String) = orchestrator.dismissSubscription(subId)
// Refreshes observers in batches.
private val bundler = BundledUpdate(sampleTime, Dispatchers.IO)
override fun invalidateFilters(ignoreIfDoing: Boolean) {
bundler.invalidate(ignoreIfDoing, ::forceInvalidate)
}
fun forceInvalidate() {
updateSubscriptions(allKeys())
orchestrator.updateRelays()
}
override fun destroy() {
bundler.cancel()
if (isDebug) {
Log.d(logTag, "Destroy, Unsubscribe")
}
}
}
@@ -0,0 +1,27 @@
/**
* 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.relayClient.eoseManagers
interface IEoseManager {
fun invalidateFilters(ignoreIfDoing: Boolean = false)
fun destroy()
}
@@ -0,0 +1,162 @@
/**
* 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.relayClient.eoseManagers
import com.vitorpamplona.amethyst.commons.relays.EOSECache
import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Generic per-key EOSE manager that creates a subscription for each unique key.
*
* This query type creates a new relay subscription for every distinct key K
* extracted from query state T. It is ideal for screens that need separate
* subscriptions per entity (user, thread, etc.).
*
* @param T The query state type (e.g., ThreadQueryState)
* @param K The key type used to deduplicate subscriptions (e.g., String noteId)
*
* This class keeps EOSEs for each key for as long as possible and can be
* shared among multiple query states that map to the same key.
*/
abstract class PerKeyEoseManager<T, K : Any>(
client: INostrClient,
allKeys: () -> Set<T>,
val invalidateAfterEose: Boolean = false,
cacheSize: Int = 200,
) : BaseEoseManager<T>(client, allKeys) {
// EOSE cache keyed by K
private val latestEOSEs = EOSECache<K>(cacheSize)
// Map from key K to subscription ID
private val keySubscriptionMap = mutableMapOf<K, String>()
/**
* Get the since map for a query state's key.
*/
fun since(queryState: T): SincePerRelayMap? = latestEOSEs.since(extractKey(queryState))
/**
* Record a new EOSE for a query state.
*/
open fun newEose(
queryState: T,
relayUrl: NormalizedRelayUrl,
time: Long,
filters: List<Filter>? = null,
) {
latestEOSEs.newEose(extractKey(queryState), relayUrl, time)
if (invalidateAfterEose) {
invalidateFilters()
}
}
/**
* Create a new subscription for a query state.
*/
open fun newSub(queryState: T): Subscription =
requestNewSubscription(
object : IRequestListener {
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
newEose(queryState, relay, TimeUtils.now(), forFilters)
}
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (isLive) {
newEose(queryState, relay, TimeUtils.now(), forFilters)
}
}
},
)
/**
* End a subscription for a key.
*/
open fun endSub(
key: K,
subId: String,
) {
dismissSubscription(subId)
keySubscriptionMap.remove(key)
}
/**
* Find or create a subscription for a query state.
*/
fun findOrCreateSubFor(queryState: T): Subscription {
val key = extractKey(queryState)
val subId = keySubscriptionMap[key]
return if (subId == null) {
newSub(queryState).also { keySubscriptionMap[key] = it.id }
} else {
getSubscription(subId) ?: newSub(queryState).also { keySubscriptionMap[key] = it.id }
}
}
override fun updateSubscriptions(keys: Set<T>) {
val uniqueByKey = keys.distinctBy { extractKey(it) }
val updatedKeys = mutableSetOf<K>()
uniqueByKey.forEach { queryState ->
val key = extractKey(queryState)
val newFilters = updateFilter(queryState, since(queryState))?.ifEmpty { null }
findOrCreateSubFor(queryState).updateFilters(newFilters?.groupByRelay())
updatedKeys.add(key)
}
// Clean up subscriptions for keys no longer active
keySubscriptionMap.filter { it.key !in updatedKeys }.forEach {
endSub(it.key, it.value)
}
}
/**
* Build filters for a query state.
* @return List of relay-based filters, or null to clear the subscription
*/
abstract fun updateFilter(
queryState: T,
since: SincePerRelayMap?,
): List<RelayBasedFilter>?
/**
* Extract the deduplication key from a query state.
* Subscriptions are shared among query states with the same key.
*/
abstract fun extractKey(queryState: T): K
}
@@ -0,0 +1,103 @@
/**
* 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.relayClient.eoseManagers
import com.vitorpamplona.amethyst.commons.relays.EOSERelayList
import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* This query type creates only ONE relay subscription. It filters duplicates
* by DistinctById.uniqueId() but it doesn't create a new subscription for each.
*
* All filters are passed as a single sub.
*
* It is ideal for temporary filters, including event, user finding that must be
* disabled after the user is found.
*
* This class keeps EOSEs for as long as possible and
* shares all EOSEs among all users.
*/
abstract class SingleSubEoseManager<T>(
client: INostrClient,
allKeys: () -> Set<T>,
val invalidateAfterEose: Boolean = false,
) : BaseEoseManager<T>(client, allKeys) {
// long term EOSE cache
private val latestEOSEs = EOSERelayList()
fun since() = latestEOSEs.since()
open fun newEose(
relay: NormalizedRelayUrl,
time: Long,
filters: List<Filter>? = null,
) {
latestEOSEs.newEose(relay, time)
if (invalidateAfterEose) {
invalidateFilters()
}
}
val sub =
requestNewSubscription(
object : IRequestListener {
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
newEose(relay, TimeUtils.now(), forFilters)
}
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (isLive) {
newEose(relay, TimeUtils.now(), forFilters)
}
}
},
)
override fun updateSubscriptions(keys: Set<T>) {
val uniqueSubscribedAccounts = keys.distinctBy { distinct(it) }
val newFilters = updateFilter(uniqueSubscribedAccounts, since())?.ifEmpty { null }
sub.updateFilters(newFilters?.groupByRelay())
}
abstract fun updateFilter(
keys: List<T>,
since: SincePerRelayMap?,
): List<RelayBasedFilter>?
abstract fun distinct(key: T): Any
}
@@ -0,0 +1,109 @@
/**
* 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.relayClient.preload
import com.vitorpamplona.amethyst.commons.model.User
/**
* Preloads user metadata and avatar images for feed items.
* Coordinates between metadata subscription (rate-limited) and image preloading.
*
* Priority order:
* 1. Metadata (display names, avatar URLs) - FIRST
* 2. Avatar images (prefetch when metadata arrives) - SECOND
*/
class MetadataPreloader(
private val rateLimiter: MetadataRateLimiter,
private val imagePrefetcher: ImagePrefetcher? = null,
) {
/**
* Queue users for metadata preloading.
* If user already has metadata, prefetch their avatar image.
* Otherwise, queue for metadata subscription.
*/
fun preloadForUsers(users: Collection<User>) {
users.forEach { user ->
val metadata = user.info
if (metadata != null) {
// Already have metadata, prefetch avatar
metadata.picture?.let { avatarUrl ->
imagePrefetcher?.prefetch(avatarUrl)
}
} else {
// Need to fetch metadata first
rateLimiter.enqueue(user.pubkeyHex)
}
}
}
/**
* Queue a single user for metadata preloading.
*/
fun preloadForUser(user: User) {
val metadata = user.info
if (metadata != null) {
metadata.picture?.let { avatarUrl ->
imagePrefetcher?.prefetch(avatarUrl)
}
} else {
rateLimiter.enqueue(user.pubkeyHex)
}
}
/**
* Called when metadata arrives for a user.
* Triggers avatar image prefetch.
*/
fun onMetadataReceived(user: User) {
user.info?.picture?.let { avatarUrl ->
imagePrefetcher?.prefetch(avatarUrl)
}
}
/**
* Prefetch avatar images for users that already have metadata.
*/
fun prefetchAvatars(users: Collection<User>) {
users.forEach { user ->
user.info?.picture?.let { avatarUrl ->
imagePrefetcher?.prefetch(avatarUrl)
}
}
}
}
/**
* Interface for image prefetching.
* Platform-specific implementations use Coil (Android) or similar (Desktop).
*/
interface ImagePrefetcher {
/**
* Prefetch an image URL into the cache.
*/
fun prefetch(url: String)
/**
* Prefetch multiple image URLs.
*/
fun prefetchAll(urls: Collection<String>) {
urls.forEach { prefetch(it) }
}
}
@@ -0,0 +1,109 @@
/**
* 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.relayClient.preload
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.launch
/**
* Global rate limiter for metadata requests to prevent thundering herd on fast scroll.
* Batches pubkeys and processes at a controlled rate.
*
* @param maxRequestsPerSecond Maximum requests to process per second (default 20)
* @param scope CoroutineScope for processing
*/
class MetadataRateLimiter(
private val maxRequestsPerSecond: Int = 20,
private val scope: CoroutineScope,
) {
private val queue = Channel<String>(Channel.BUFFERED)
private val processed = mutableSetOf<String>()
/**
* Enqueue a pubkey for metadata fetching.
* Duplicates within the same batch are automatically filtered.
*/
fun enqueue(pubkey: String) {
if (pubkey !in processed) {
queue.trySend(pubkey)
}
}
/**
* Enqueue multiple pubkeys for metadata fetching.
*/
fun enqueueAll(pubkeys: Collection<String>) {
pubkeys.forEach { enqueue(it) }
}
/**
* Start processing the queue with rate limiting.
* @param onRequest Callback invoked for each pubkey to process
*/
fun start(onRequest: suspend (String) -> Unit) {
scope.launch {
val batch = mutableListOf<String>()
queue.consumeAsFlow().collect { pubkey ->
if (pubkey !in processed) {
batch.add(pubkey)
processed.add(pubkey)
}
// Process batch when we hit the limit or queue is empty
if (batch.size >= maxRequestsPerSecond) {
processBatch(batch, onRequest)
batch.clear()
delay(1000) // Wait 1 second before next batch
}
}
// Process remaining
if (batch.isNotEmpty()) {
processBatch(batch, onRequest)
}
}
}
private suspend fun processBatch(
batch: List<String>,
onRequest: suspend (String) -> Unit,
) {
batch.forEach { pubkey ->
onRequest(pubkey)
}
}
/**
* Clear the processed set to allow re-fetching.
* Call this when switching accounts or clearing cache.
*/
fun reset() {
processed.clear()
}
/**
* Check if a pubkey has already been processed.
*/
fun isProcessed(pubkey: String): Boolean = pubkey in processed
}
@@ -0,0 +1,49 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.relayClient.subscriptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.MutableComposeSubscriptionManager
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.MutableQueryState
@Composable
fun <T> KeyDataSourceSubscription(
state: T,
dataSource: ComposeSubscriptionManager<T>,
) = DisposableEffect(state) {
dataSource.subscribe(state)
onDispose {
dataSource.unsubscribe(state)
}
}
@Composable
fun <T : MutableQueryState> KeyDataSourceSubscription(
state: T,
dataSource: MutableComposeSubscriptionManager<T>,
) = DisposableEffect(state) {
dataSource.subscribe(state)
onDispose {
dataSource.unsubscribe(state)
}
}
@@ -0,0 +1,129 @@
/**
* 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.relayClient.subscriptions
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.launch
/**
* A subscription request with its priority.
*/
data class PrioritizedFilter(
val priority: SubscriptionPriority,
val filter: Filter,
val tag: String? = null,
)
/**
* Queue that processes subscription requests in priority order.
* Ensures metadata loads before reactions, reactions before replies, etc.
*
* Usage:
* ```
* val queue = PrioritizedSubscriptionQueue(scope)
* queue.start { filter -> relayClient.subscribe(filter) }
*
* // Add subscriptions - they'll be processed in priority order
* queue.enqueue(SubscriptionPriority.METADATA, metadataFilter)
* queue.enqueue(SubscriptionPriority.REACTIONS, reactionsFilter)
* ```
*/
class PrioritizedSubscriptionQueue(
private val scope: CoroutineScope,
) {
// Separate channels per priority for efficient processing
private val queues =
SubscriptionPriority.entries.associateWith {
Channel<PrioritizedFilter>(Channel.BUFFERED)
}
private var isRunning = false
/**
* Enqueue a filter with the given priority.
*/
fun enqueue(
priority: SubscriptionPriority,
filter: Filter,
tag: String? = null,
) {
queues[priority]?.trySend(PrioritizedFilter(priority, filter, tag))
}
/**
* Enqueue multiple filters with the same priority.
*/
fun enqueueAll(
priority: SubscriptionPriority,
filters: List<Filter>,
tag: String? = null,
) {
filters.forEach { enqueue(priority, it, tag) }
}
/**
* Start processing the queue.
* Processes higher priority items first within each batch.
*
* @param onSubscribe Callback to execute the subscription
*/
fun start(onSubscribe: suspend (Filter) -> Unit) {
if (isRunning) return
isRunning = true
// Process each priority queue in order
SubscriptionPriority.sortedByPriority().forEach { priority ->
scope.launch {
queues[priority]?.consumeAsFlow()?.collect { item ->
onSubscribe(item.filter)
}
}
}
}
/**
* Process all pending items immediately in priority order.
* Useful for batch operations.
*/
suspend fun flush(onSubscribe: suspend (Filter) -> Unit) {
SubscriptionPriority.sortedByPriority().forEach { priority ->
val queue = queues[priority] ?: return@forEach
while (true) {
val item = queue.tryReceive().getOrNull() ?: break
onSubscribe(item.filter)
}
}
}
/**
* Clear all pending items.
*/
fun clear() {
queues.values.forEach { channel ->
while (channel.tryReceive().isSuccess) {
// Drain the channel
}
}
}
}
@@ -0,0 +1,55 @@
/**
* 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.relayClient.subscriptions
/**
* Priority levels for relay subscriptions.
* Lower order = higher priority = processed first.
*
* Priority order ensures progressive rendering:
* 1. METADATA - Display names, avatars load first
* 2. REACTIONS - Like/zap counts load second
* 3. REPLIES - Reply counts
* 4. CONTENT - Additional content
*/
enum class SubscriptionPriority(
val order: Int,
) {
/** User metadata (Kind 0) - display names, avatars. Highest priority. */
METADATA(1),
/** Reactions (Kind 7) - likes, zaps, reposts */
REACTIONS(2),
/** Replies - reply counts and threads */
REPLIES(3),
/** Additional content - lower priority items */
CONTENT(4),
;
companion object {
/**
* Get priorities sorted by order (highest priority first).
*/
fun sortedByPriority(): List<SubscriptionPriority> = entries.sortedBy { it.order }
}
}
@@ -0,0 +1,144 @@
/**
* 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.relays
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* Generic EOSE cache keyed by any type K.
* KMP-compatible version (no LruCache dependency).
*
* For memory-constrained environments, consider using platform-specific
* LRU implementations in androidMain/jvmMain.
*/
open class EOSECache<K : Any>(
private val maxSize: Int = 200,
) {
private val cache = linkedMapOf<K, EOSERelayList>()
private val lock = Any()
fun addOrUpdate(
key: K,
relayUrl: NormalizedRelayUrl,
time: Long,
) {
synchronized(lock) {
val relayList = cache[key]
if (relayList == null) {
// Evict oldest if at capacity
if (cache.size >= maxSize) {
cache.remove(cache.keys.first())
}
val newList = EOSERelayList()
newList.addOrUpdate(relayUrl, time)
cache[key] = newList
} else {
relayList.addOrUpdate(relayUrl, time)
}
}
}
fun since(key: K): SincePerRelayMap? =
synchronized(lock) {
cache[key]?.relayList?.toMutableMap()
}
fun newEose(
key: K,
relayUrl: NormalizedRelayUrl,
time: Long,
) = addOrUpdate(key, relayUrl, time)
fun remove(key: K) {
synchronized(lock) {
cache.remove(key)
}
}
fun clear() {
synchronized(lock) {
cache.clear()
}
}
fun size(): Int = synchronized(lock) { cache.size }
}
/**
* Two-level EOSE cache: outer key -> inner key -> relay list.
* Useful for user + list combinations.
*/
open class EOSETwoLevelCache<K1 : Any, K2 : Any>(
private val outerMaxSize: Int = 20,
private val innerMaxSize: Int = 200,
) {
private val cache = linkedMapOf<K1, EOSECache<K2>>()
private val lock = Any()
fun addOrUpdate(
outerKey: K1,
innerKey: K2,
relayUrl: NormalizedRelayUrl,
time: Long,
) {
synchronized(lock) {
val innerCache = cache[outerKey]
if (innerCache == null) {
// Evict oldest if at capacity
if (cache.size >= outerMaxSize) {
cache.remove(cache.keys.first())
}
val newCache = EOSECache<K2>(innerMaxSize)
newCache.addOrUpdate(innerKey, relayUrl, time)
cache[outerKey] = newCache
} else {
innerCache.addOrUpdate(innerKey, relayUrl, time)
}
}
}
fun since(
outerKey: K1,
innerKey: K2,
): SincePerRelayMap? =
synchronized(lock) {
cache[outerKey]?.since(innerKey)
}
fun newEose(
outerKey: K1,
innerKey: K2,
relayUrl: NormalizedRelayUrl,
time: Long,
) = addOrUpdate(outerKey, innerKey, relayUrl, time)
fun removeOuter(key: K1) {
synchronized(lock) {
cache.remove(key)
}
}
fun clear() {
synchronized(lock) {
cache.clear()
}
}
}
@@ -0,0 +1,127 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.search
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
import com.vitorpamplona.quartz.utils.Hex
/**
* Parses search input and returns matching results.
* Supports: npub, nprofile, nsec (extracts pubkey), note, nevent, naddr, hex keys, hashtags
*
* Shared between Android and Desktop for consistent Bech32 parsing.
*/
fun parseSearchInput(input: String): List<SearchResult> {
if (input.isBlank()) return emptyList()
val trimmed = input.trim()
val results = mutableListOf<SearchResult>()
// Check for hashtag
if (trimmed.startsWith("#") && trimmed.length > 1) {
results.add(SearchResult.HashtagResult(trimmed.substring(1)))
return results
}
// Try to parse as Bech32 (npub, nevent, naddr, etc.)
val parsed = Nip19Parser.uriToRoute(trimmed)?.entity
if (parsed != null) {
when (parsed) {
is NPub -> {
results.add(
SearchResult.UserResult(
pubKeyHex = parsed.hex,
displayId = trimmed.take(20) + "...",
),
)
}
is NProfile -> {
results.add(
SearchResult.UserResult(
pubKeyHex = parsed.hex,
displayId = trimmed.take(20) + "...",
),
)
}
is NSec -> {
results.add(
SearchResult.UserResult(
pubKeyHex = parsed.toPubKeyHex(),
displayId = "User from nsec",
),
)
}
is NNote -> {
results.add(
SearchResult.NoteResult(
noteIdHex = parsed.hex,
displayId = trimmed.take(20) + "...",
),
)
}
is NEvent -> {
results.add(
SearchResult.NoteResult(
noteIdHex = parsed.hex,
displayId = trimmed.take(20) + "...",
),
)
}
is NAddress -> {
results.add(
SearchResult.AddressResult(
kind = parsed.kind,
pubKeyHex = parsed.author,
dTag = parsed.dTag,
displayId = trimmed.take(20) + "...",
),
)
}
else -> { }
}
return results
}
// Try to parse as hex (64-char pubkey or event id)
if (trimmed.length == 64 && Hex.isHex(trimmed)) {
results.add(
SearchResult.UserResult(
pubKeyHex = trimmed,
displayId = trimmed.take(16) + "..." + trimmed.takeLast(8),
),
)
results.add(
SearchResult.NoteResult(
noteIdHex = trimmed,
displayId = trimmed.take(16) + "..." + trimmed.takeLast(8),
),
)
return results
}
return results
}
@@ -0,0 +1,69 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.search
import com.vitorpamplona.amethyst.commons.model.User
/**
* Represents a parsed search result from Bech32/hex input.
* Shared between Android and Desktop for consistent search behavior.
*/
sealed class SearchResult {
/**
* Direct user lookup from npub, nprofile, nsec, or hex pubkey.
*/
data class UserResult(
val pubKeyHex: String,
val displayId: String,
) : SearchResult()
/**
* User from local cache with full metadata.
*/
data class CachedUserResult(
val user: User,
) : SearchResult()
/**
* Note lookup from note1 or nevent.
*/
data class NoteResult(
val noteIdHex: String,
val displayId: String,
) : SearchResult()
/**
* Addressable event lookup from naddr.
*/
data class AddressResult(
val kind: Int,
val pubKeyHex: String,
val dTag: String,
val displayId: String,
) : SearchResult()
/**
* Hashtag search.
*/
data class HashtagResult(
val hashtag: String,
) : SearchResult()
}
@@ -90,6 +90,7 @@ class EventCollectionState<T : Any>(
mutex.withLock {
val itemId = getId(item)
if (itemId !in seenIds) {
seenIds.add(itemId)
pendingItems.add(item)
scheduleBatchUpdate()
}
@@ -108,6 +109,7 @@ class EventCollectionState<T : Any>(
mutex.withLock {
val newItems = items.filter { getId(it) !in seenIds }
if (newItems.isNotEmpty()) {
newItems.forEach { seenIds.add(getId(it)) }
pendingItems.addAll(newItems)
scheduleBatchUpdate()
}
@@ -191,8 +193,7 @@ class EventCollectionState<T : Any>(
mutex.withLock {
if (pendingItems.isEmpty()) return
// Add pending IDs to seenIds
pendingItems.forEach { seenIds.add(getId(it)) }
// seenIds already updated in addItem/addItems
// Merge with existing items
val merged = _items.value + pendingItems
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.commons.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Face
@@ -62,8 +63,10 @@ fun UserAvatar(
loadRobohash: Boolean = true,
) {
val avatarModifier =
remember(size) {
modifier.clip(shape = CircleShape)
remember(size, modifier) {
modifier
.size(size)
.clip(shape = CircleShape)
}
if (pictureUrl != null && loadProfilePicture) {
@@ -0,0 +1,105 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.User
/**
* A card displaying user search result with avatar, name, and nip05/pubkey.
* Shared between Android and Desktop search screens.
*/
@Composable
fun UserSearchCard(
user: User,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(
modifier =
modifier
.fillMaxWidth()
.clickable(onClick = onClick),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Row(
modifier = Modifier.padding(12.dp).fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
UserAvatar(
userHex = user.pubkeyHex,
pictureUrl = user.profilePicture(),
size = 40.dp,
contentDescription = "Profile picture",
)
Column(modifier = Modifier.weight(1f)) {
Text(
user.toBestDisplayName(),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
)
val nip05 = user.nip05()
if (nip05 != null) {
Text(
nip05,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
} else {
Text(
user.pubkeyDisplayHex(),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Icon(
Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = "Navigate",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -0,0 +1,78 @@
/**
* 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.notifications
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.ui.feeds.LoadedFeedState
import kotlinx.coroutines.flow.MutableStateFlow
/**
* Base interface for notification cards.
*
* Cards represent grouped or individual notification items in the feed.
* Platform implementations provide concrete card types (NoteCard, MultiSetCard, etc.).
*/
@Immutable
interface Card {
/**
* Returns the creation timestamp of this card.
* Used for sorting cards in chronological order.
*/
fun createdAt(): Long
/**
* Returns a unique identifier for this card.
* Used for list diffing and deduplication.
*/
fun id(): String
}
/**
* Feed state for notification cards.
*
* Mirrors FeedState but specifically typed for Card content.
*/
@Stable
sealed class CardFeedState {
@Immutable
object Loading : CardFeedState()
@Stable
class Loaded(
val feed: MutableStateFlow<LoadedFeedState<Card>>,
) : CardFeedState()
@Immutable
object Empty : CardFeedState()
@Immutable
class FeedError(
val errorMessage: String,
) : CardFeedState()
}
/**
* Comparator for sorting cards by creation time (newest first).
*/
val DefaultCardComparator: Comparator<Card> =
compareByDescending<Card> { it.createdAt() }
.thenByDescending { it.id() }
@@ -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
}
}
}
@@ -0,0 +1,149 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.model.nip51Bookmarks
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
/**
* Handles NIP-51 bookmark operations.
* Shared between Android and Desktop.
*/
object BookmarkAction {
/**
* Creates a new bookmark list with a single event bookmarked.
*/
suspend fun createWithBookmark(
eventId: HexKey,
relayHint: NormalizedRelayUrl? = null,
isPrivate: Boolean = false,
signer: NostrSigner,
): BookmarkListEvent {
val bookmark = EventBookmark(eventId, relayHint)
return BookmarkListEvent.create(
bookmarkIdTag = bookmark,
isPrivate = isPrivate,
signer = signer,
)
}
/**
* Adds an event to an existing bookmark list.
*/
suspend fun addBookmark(
existingList: BookmarkListEvent,
eventId: HexKey,
relayHint: NormalizedRelayUrl? = null,
isPrivate: Boolean = false,
signer: NostrSigner,
): BookmarkListEvent {
val bookmark = EventBookmark(eventId, relayHint)
return BookmarkListEvent.add(
earlierVersion = existingList,
bookmarkIdTag = bookmark,
isPrivate = isPrivate,
signer = signer,
)
}
/**
* Removes an event from a bookmark list.
* Checks both public and private bookmarks.
*/
suspend fun removeBookmark(
existingList: BookmarkListEvent,
eventId: HexKey,
signer: NostrSigner,
): BookmarkListEvent {
val bookmark = EventBookmark(eventId)
return BookmarkListEvent.remove(
earlierVersion = existingList,
bookmarkIdTag = bookmark,
signer = signer,
)
}
/**
* Removes an event from a bookmark list (public or private specifically).
*/
suspend fun removeBookmark(
existingList: BookmarkListEvent,
eventId: HexKey,
isPrivate: Boolean,
signer: NostrSigner,
): BookmarkListEvent {
val bookmark = EventBookmark(eventId)
return BookmarkListEvent.remove(
earlierVersion = existingList,
bookmarkIdTag = bookmark,
isPrivate = isPrivate,
signer = signer,
)
}
/**
* Checks if an event ID is in the public bookmarks.
*/
fun isInPublicBookmarks(
bookmarkList: BookmarkListEvent?,
eventId: HexKey,
): Boolean {
if (bookmarkList == null) return false
return bookmarkList.publicBookmarks().any {
it is EventBookmark && it.eventId == eventId
}
}
/**
* Checks if an event ID is in the private bookmarks.
* Requires decryption via signer.
*/
suspend fun isInPrivateBookmarks(
bookmarkList: BookmarkListEvent?,
eventId: HexKey,
signer: NostrSigner,
): Boolean {
if (bookmarkList == null) return false
val privateBookmarks = bookmarkList.privateBookmarks(signer) ?: return false
return privateBookmarks.any {
it is EventBookmark && it.eventId == eventId
}
}
/**
* Checks if an event ID is bookmarked (public or private).
*/
suspend fun isBookmarked(
bookmarkList: BookmarkListEvent?,
eventId: HexKey,
signer: NostrSigner,
): Boolean =
isInPublicBookmarks(bookmarkList, eventId) ||
isInPrivateBookmarks(bookmarkList, eventId, signer)
/**
* Gets the bookmark list address for a user.
*/
fun getBookmarkListAddress(pubKey: HexKey) = BookmarkListEvent.createBookmarkAddress(pubKey)
}
@@ -0,0 +1,163 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.model.nip57Zaps
import com.vitorpamplona.amethyst.commons.services.lnurl.LightningAddressResolver
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
/**
* Handles NIP-57 zap requests and invoice fetching.
* Shared between Android and Desktop.
*/
object ZapAction {
/**
* Result of a zap operation.
*/
sealed class ZapResult {
data class Invoice(
val bolt11: String,
) : ZapResult()
data class Error(
val message: String,
) : ZapResult()
}
/**
* Creates a zap request and fetches a BOLT11 invoice.
*
* @param targetEvent Event to zap
* @param lnAddress Lightning address of recipient
* @param amountSats Amount in satoshis
* @param message Optional zap message
* @param relays Relay hints (normalized URLs)
* @param signer Signer for the request
* @param resolver Lightning address resolver
* @param zapType Type of zap (default PUBLIC)
* @param onProgress Progress callback
*/
suspend fun fetchZapInvoice(
targetEvent: Event,
lnAddress: String,
amountSats: Long,
message: String = "",
relays: Set<NormalizedRelayUrl>,
signer: NostrSigner,
resolver: LightningAddressResolver,
zapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC,
onProgress: (Float) -> Unit = {},
): ZapResult {
if (!signer.isWriteable()) {
return ZapResult.Error("Signer is not writeable")
}
// Create zap request using quartz factory
val zapRequest =
try {
LnZapRequestEvent.create(
zappedEvent = targetEvent,
relays = relays,
signer = signer,
pollOption = null,
message = message,
zapType = zapType,
toUserPubHex = null,
)
} catch (e: Exception) {
return ZapResult.Error("Failed to create zap request: ${e.message}")
}
onProgress(0.3f)
// Fetch invoice
val result =
resolver.fetchInvoice(
lnAddress = lnAddress,
milliSats = amountSats * 1000,
message = message,
zapRequest = zapRequest,
onProgress = { progress ->
onProgress(0.3f + progress * 0.7f)
},
)
return when (result) {
is LightningAddressResolver.Result.Success -> ZapResult.Invoice(result.invoice)
is LightningAddressResolver.Result.Error -> ZapResult.Error(result.message)
}
}
/**
* Creates a zap request for a user profile (no event).
*/
suspend fun fetchZapInvoiceForUser(
userPubHex: String,
lnAddress: String,
amountSats: Long,
message: String = "",
relays: Set<NormalizedRelayUrl>,
signer: NostrSigner,
resolver: LightningAddressResolver,
zapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC,
onProgress: (Float) -> Unit = {},
): ZapResult {
if (!signer.isWriteable()) {
return ZapResult.Error("Signer is not writeable")
}
// Create zap request for user
val zapRequest =
try {
LnZapRequestEvent.create(
userHex = userPubHex,
relays = relays,
signer = signer,
message = message,
zapType = zapType,
)
} catch (e: Exception) {
return ZapResult.Error("Failed to create zap request: ${e.message}")
}
onProgress(0.3f)
// Fetch invoice
val result =
resolver.fetchInvoice(
lnAddress = lnAddress,
milliSats = amountSats * 1000,
message = message,
zapRequest = zapRequest,
onProgress = { progress ->
onProgress(0.3f + progress * 0.7f)
},
)
return when (result) {
is LightningAddressResolver.Result.Success -> ZapResult.Invoice(result.invoice)
is LightningAddressResolver.Result.Error -> ZapResult.Error(result.message)
}
}
}
@@ -0,0 +1,224 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.services.lnurl
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
import com.vitorpamplona.quartz.lightning.Lud06
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.math.BigDecimal
import java.math.RoundingMode
import java.net.URLEncoder
import kotlin.coroutines.cancellation.CancellationException
/**
* Platform-agnostic Lightning Address resolver for LNURL-pay flow.
* Shared between Android and Desktop for zap functionality.
*
* Flow:
* 1. Lightning address (user@domain) → LNURL endpoint URL
* 2. Fetch LNURL-pay JSON → extract callback URL
* 3. Call callback with amount → get BOLT11 invoice
*/
class LightningAddressResolver(
private val httpClient: OkHttpClient,
) {
private val mapper = jacksonObjectMapper()
/**
* Result of resolving a lightning address to a BOLT11 invoice.
*/
sealed class Result {
data class Success(
val invoice: String,
) : Result()
data class Error(
val message: String,
) : Result()
}
/**
* Converts a lightning address to its LNURL-pay endpoint URL.
* Supports: user@domain, LNURL bech32
*/
fun assembleUrl(lnAddress: String): String? {
val parts = lnAddress.split("@")
if (parts.size == 2) {
return "https://${parts[1]}/.well-known/lnurlp/${parts[0]}"
}
if (lnAddress.lowercase().startsWith("lnurl")) {
return Lud06().toLnUrlp(lnAddress)
}
return null
}
/**
* Resolves a lightning address to a BOLT11 invoice.
*
* @param lnAddress Lightning address (user@domain) or LNURL
* @param milliSats Amount in millisatoshis
* @param message Optional comment for the payment
* @param zapRequest Optional NIP-57 zap request event
* @param onProgress Progress callback (0.0 to 1.0)
*/
suspend fun fetchInvoice(
lnAddress: String,
milliSats: Long,
message: String = "",
zapRequest: LnZapRequestEvent? = null,
onProgress: (Float) -> Unit = {},
): Result =
withContext(Dispatchers.IO) {
try {
// Step 1: Resolve LN address to LNURL endpoint
val url =
assembleUrl(lnAddress)
?: return@withContext Result.Error("Invalid lightning address: $lnAddress")
onProgress(0.2f)
// Step 2: Fetch LNURL-pay JSON
val lnurlJson =
fetchUrl(url)
?: return@withContext Result.Error("Failed to fetch LNURL endpoint: $url")
onProgress(0.4f)
val lnurlp =
try {
mapper.readTree(lnurlJson)
} catch (e: Exception) {
return@withContext Result.Error("Failed to parse LNURL response")
}
val callbackUrl =
lnurlp?.get("callback")?.asText()?.ifBlank { null }
?: return@withContext Result.Error("No callback URL in LNURL response")
val allowsNostr = lnurlp.get("allowsNostr")?.asBoolean() ?: false
onProgress(0.5f)
// Step 3: Fetch invoice from callback
val invoiceJson =
fetchInvoiceFromCallback(
callbackUrl = callbackUrl,
milliSats = milliSats,
message = message,
zapRequest = if (allowsNostr) zapRequest else null,
) ?: return@withContext Result.Error("Failed to fetch invoice from callback")
onProgress(0.7f)
val invoiceResponse =
try {
mapper.readTree(invoiceJson)
} catch (e: Exception) {
return@withContext Result.Error("Failed to parse invoice response")
}
val pr = invoiceResponse?.get("pr")?.asText()?.ifBlank { null }
if (pr == null) {
val reason = invoiceResponse?.get("reason")?.asText()?.ifBlank { null }
return@withContext Result.Error(reason ?: "No invoice in response")
}
// Step 4: Validate invoice amount
val expectedAmountInSats =
BigDecimal(milliSats)
.divide(BigDecimal(1000), RoundingMode.HALF_UP)
.toLong()
val invoiceAmount = LnInvoiceUtil.getAmountInSats(pr)
if (invoiceAmount.toLong() != expectedAmountInSats) {
return@withContext Result.Error(
"Invoice amount mismatch: got ${invoiceAmount.toLong()} sats, expected $expectedAmountInSats sats",
)
}
onProgress(1.0f)
Result.Success(pr)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.Error(e.message ?: "Unknown error")
}
}
private suspend fun fetchUrl(url: String): String? =
withContext(Dispatchers.IO) {
try {
val request = Request.Builder().url(url).build()
httpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) {
response.body?.string()
} else {
null
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
}
private suspend fun fetchInvoiceFromCallback(
callbackUrl: String,
milliSats: Long,
message: String,
zapRequest: LnZapRequestEvent?,
): String? =
withContext(Dispatchers.IO) {
try {
val encodedMessage = URLEncoder.encode(message, "utf-8")
val urlBinder = if (callbackUrl.contains("?")) "&" else "?"
var url = "$callbackUrl${urlBinder}amount=$milliSats&comment=$encodedMessage"
if (zapRequest != null) {
val encodedRequest = URLEncoder.encode(zapRequest.toJson(), "utf-8")
url += "&nostr=$encodedRequest"
}
val request = Request.Builder().url(url).build()
httpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) {
response.body?.string()
} else {
null
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
}
}
@@ -0,0 +1,97 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.services.nwc
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
import java.util.concurrent.ConcurrentHashMap
/**
* Tracks pending NIP-47 (Nostr Wallet Connect) payment requests awaiting responses.
*
* Shared between Android and Desktop to provide consistent payment tracking behavior.
* Platform-specific caches (LocalCache, DesktopLocalCache) delegate to this tracker
* for the core request/response matching logic.
*
* Flow:
* 1. When sending payment request: [registerRequest] stores callback
* 2. When response arrives: [onResponseReceived] retrieves and removes pending request
* 3. Caller invokes callback and links notes via Note.addZapPayment()
*/
class NwcPaymentTracker {
/**
* Data for a pending payment request.
*
* @property zappedNote The note being zapped, if payment is for a zap
* @property onResponse Callback to invoke when wallet responds
*/
data class PendingRequest(
val zappedNote: Note?,
val onResponse: suspend (LnZapPaymentResponseEvent) -> Unit,
)
private val awaitingRequests = ConcurrentHashMap<HexKey, PendingRequest>(10)
/**
* Registers a pending payment request.
*
* @param requestId Event ID of the LnZapPaymentRequestEvent
* @param zappedNote The note being zapped (null if not a zap payment)
* @param onResponse Callback invoked when response arrives
*/
fun registerRequest(
requestId: HexKey,
zappedNote: Note?,
onResponse: suspend (LnZapPaymentResponseEvent) -> Unit,
) {
awaitingRequests[requestId] = PendingRequest(zappedNote, onResponse)
}
/**
* Called when a payment response event is received.
* Retrieves and removes the pending request for the given request ID.
*
* @param requestId The 'e' tag from the response, pointing to original request
* @return PendingRequest if found, null otherwise
*/
fun onResponseReceived(requestId: HexKey?): PendingRequest? {
if (requestId == null) return null
return awaitingRequests.remove(requestId)
}
/**
* Checks if there's a pending request for the given ID.
*/
fun hasPendingRequest(requestId: HexKey): Boolean = awaitingRequests.containsKey(requestId)
/**
* Manually removes a pending request (e.g., on timeout).
*/
fun cleanup(requestId: HexKey) {
awaitingRequests.remove(requestId)
}
/**
* Returns count of pending requests (for debugging/monitoring).
*/
fun pendingCount(): Int = awaitingRequests.size
}