Merge pull request #1676 from nrobi144/nrobi144/phase-2A
Desktop: Basic Search + Bookmarks + Zaps
This commit is contained in:
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
+221
@@ -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()
|
||||
}
|
||||
}
|
||||
+93
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+92
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
@@ -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
|
||||
}
|
||||
+29
@@ -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()
|
||||
}
|
||||
+78
@@ -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<*>
|
||||
}
|
||||
+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.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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -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()
|
||||
}
|
||||
+162
@@ -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
|
||||
}
|
||||
+103
@@ -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
|
||||
}
|
||||
+109
@@ -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) }
|
||||
}
|
||||
}
|
||||
+109
@@ -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
|
||||
}
|
||||
+49
@@ -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)
|
||||
}
|
||||
}
|
||||
+129
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+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 {
|
||||
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
|
||||
|
||||
+5
-2
@@ -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) {
|
||||
|
||||
+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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
@@ -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() }
|
||||
+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
|
||||
}
|
||||
}
|
||||
}
|
||||
+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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+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
|
||||
}
|
||||
Reference in New Issue
Block a user