Merge branch 'main' into claude/add-blossom-cache-support-ts8mK

This commit is contained in:
Vitor Pamplona
2026-05-08 08:15:44 -04:00
committed by GitHub
315 changed files with 46606 additions and 941 deletions
@@ -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.feeds.custom
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.collections.immutable.toImmutableList
@Stable
class FeedBuilderState(
initial: FeedDefinition? = null,
) {
var name by mutableStateOf(initial?.name ?: "")
var emoji by mutableStateOf(initial?.emoji ?: "")
var refreshMode by mutableStateOf(initial?.refreshMode ?: RefreshMode.LIVE_STREAM)
val hashtags =
mutableStateListOf<String>().apply {
(initial?.source as? FeedSource.Filter)?.hashtags?.let { addAll(it) }
}
val authors =
mutableStateListOf<HexKey>().apply {
(initial?.source as? FeedSource.Filter)?.authors?.let { addAll(it) }
}
val relays =
mutableStateListOf<String>().apply {
(initial?.source as? FeedSource.Filter)?.relays?.let { addAll(it) }
}
val excludeAuthors =
mutableStateListOf<HexKey>().apply {
(initial?.source as? FeedSource.Filter)?.excludeAuthors?.let { addAll(it) }
}
val excludeKeywords =
mutableStateListOf<String>().apply {
(initial?.source as? FeedSource.Filter)?.excludeKeywords?.let { addAll(it) }
}
val kinds =
mutableStateListOf<Int>().apply {
(initial?.source as? FeedSource.Filter)?.kinds?.let { addAll(it) }
}
val isValid: Boolean
get() = name.isNotBlank() && (hashtags.isNotEmpty() || authors.isNotEmpty() || relays.isNotEmpty())
private val editId: String? = initial?.id
fun toDefinition(): FeedDefinition {
val source =
FeedSource.Filter(
hashtags = hashtags.toImmutableList(),
authors = authors.toImmutableList(),
relays = relays.toImmutableList(),
excludeAuthors = excludeAuthors.toImmutableList(),
excludeKeywords = excludeKeywords.toImmutableList(),
kinds = kinds.toImmutableList(),
)
return FeedDefinition(
id =
editId ?: java.util.UUID
.randomUUID()
.toString(),
name = name,
emoji = emoji,
pinned = false,
pinOrder = Int.MAX_VALUE,
source = source,
refreshMode = refreshMode,
createdAt = System.currentTimeMillis() / 1000,
)
}
}
@@ -0,0 +1,88 @@
/*
* 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.feeds.custom
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Immutable
data class FeedDefinition(
val id: String,
val name: String,
val emoji: String,
val pinned: Boolean,
val pinOrder: Int,
val source: FeedSource,
val refreshMode: RefreshMode,
val createdAt: Long,
)
@Immutable
sealed interface FeedSource {
@Immutable
data class Filter(
val hashtags: ImmutableList<String> = persistentListOf(),
val authors: ImmutableList<HexKey> = persistentListOf(),
val relays: ImmutableList<String> = persistentListOf(),
val excludeAuthors: ImmutableList<HexKey> = persistentListOf(),
val excludeKeywords: ImmutableList<String> = persistentListOf(),
val kinds: ImmutableList<Int> = persistentListOf(),
) : FeedSource
@Immutable
data class PeopleList(
val kind: Int,
val pubkey: HexKey,
val dTag: String,
) : FeedSource
@Immutable
data class InterestSet(
val kind: Int,
val pubkey: HexKey,
val dTag: String,
) : FeedSource
@Immutable
data class DVM(
val kind: Int,
val pubkey: HexKey,
val dTag: String,
) : FeedSource
@Immutable
data class SingleRelay(
val url: String,
) : FeedSource
@Immutable
data object Global : FeedSource
@Immutable
data object Following : FeedSource
}
enum class RefreshMode {
LIVE_STREAM,
POLL_5MIN,
}
@@ -0,0 +1,125 @@
/*
* 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.feeds.custom
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.collections.immutable.toImmutableList
class FeedDefinitionBuilder {
var name: String = ""
var emoji: String = ""
var refreshMode: RefreshMode = RefreshMode.LIVE_STREAM
private var source: FeedSource? = null
fun filter(init: FilterBuilder.() -> Unit) {
source = FilterBuilder().apply(init).build()
}
fun fromPeopleList(
kind: Int = 30000,
pubkey: HexKey,
dTag: String,
) {
source = FeedSource.PeopleList(kind, pubkey, dTag)
}
fun fromDvm(
kind: Int = 31990,
pubkey: HexKey,
dTag: String,
) {
source = FeedSource.DVM(kind, pubkey, dTag)
}
fun fromRelay(url: String) {
source = FeedSource.SingleRelay(url)
}
fun global() {
source = FeedSource.Global
}
fun following() {
source = FeedSource.Following
}
fun build(): FeedDefinition =
FeedDefinition(
id = generateId(),
name = name,
emoji = emoji,
pinned = false,
pinOrder = Int.MAX_VALUE,
source = source ?: error("FeedDefinition requires a source"),
refreshMode = refreshMode,
createdAt = System.currentTimeMillis() / 1000,
)
private fun generateId(): String =
java.util.UUID
.randomUUID()
.toString()
}
class FilterBuilder {
val hashtags = mutableListOf<String>()
val authors = mutableListOf<HexKey>()
val relays = mutableListOf<String>()
val excludeAuthors = mutableListOf<HexKey>()
val excludeKeywords = mutableListOf<String>()
val kinds = mutableListOf<Int>()
fun build(): FeedSource.Filter =
FeedSource.Filter(
hashtags = hashtags.toImmutableList(),
authors = authors.toImmutableList(),
relays = relays.toImmutableList(),
excludeAuthors = excludeAuthors.toImmutableList(),
excludeKeywords = excludeKeywords.toImmutableList(),
kinds = kinds.toImmutableList(),
)
}
inline fun feedDefinition(init: FeedDefinitionBuilder.() -> Unit): FeedDefinition = FeedDefinitionBuilder().apply(init).build()
fun defaultFeeds(): List<FeedDefinition> =
listOf(
FeedDefinition(
id = "default-following",
name = "Following",
emoji = "\uD83C\uDFE0",
pinned = true,
pinOrder = 0,
source = FeedSource.Following,
refreshMode = RefreshMode.LIVE_STREAM,
createdAt = 0L,
),
FeedDefinition(
id = "default-global",
name = "Global",
emoji = "\uD83C\uDF10",
pinned = true,
pinOrder = 1,
source = FeedSource.Global,
refreshMode = RefreshMode.LIVE_STREAM,
createdAt = 0L,
),
)
@@ -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.feeds.custom
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
const val MAX_PINNED_FEEDS = 3
@Stable
class FeedDefinitionRepository(
private val scope: CoroutineScope,
) {
private val _feeds = MutableStateFlow<ImmutableList<FeedDefinition>>(persistentListOf())
val feeds: StateFlow<ImmutableList<FeedDefinition>> = _feeds.asStateFlow()
val groupedFeeds: StateFlow<GroupedFeeds> =
_feeds
.map { all ->
GroupedFeeds(
pinned = all.filter { it.pinned }.sortedBy { it.pinOrder }.toImmutableList(),
myFeeds = all.filter { !it.pinned && it.source !is FeedSource.DVM }.toImmutableList(),
algoFeeds = all.filter { it.source is FeedSource.DVM }.toImmutableList(),
)
}.distinctUntilChanged()
.stateIn(scope, SharingStarted.Eagerly, GroupedFeeds.EMPTY)
val pinnedFeeds: StateFlow<ImmutableList<FeedDefinition>> =
groupedFeeds
.map { it.pinned }
.distinctUntilChanged()
.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
private val _events = MutableSharedFlow<FeedEvent>(replay = 0)
val events: SharedFlow<FeedEvent> = _events.asSharedFlow()
fun load(feeds: List<FeedDefinition>) {
_feeds.value = feeds.toImmutableList()
}
fun snapshot(): List<FeedDefinition> = _feeds.value
suspend fun add(feed: FeedDefinition) {
_feeds.value = (_feeds.value + feed).toImmutableList()
_events.emit(FeedEvent.Created(feed))
}
suspend fun update(feed: FeedDefinition) {
_feeds.value =
_feeds.value
.map { if (it.id == feed.id) feed else it }
.toImmutableList()
}
suspend fun delete(id: String) {
_feeds.value = _feeds.value.filter { it.id != id }.toImmutableList()
}
suspend fun pin(id: String): Boolean {
val currentPinned = _feeds.value.count { it.pinned }
if (currentPinned >= MAX_PINNED_FEEDS) {
_events.emit(FeedEvent.PinLimitReached(MAX_PINNED_FEEDS))
return false
}
_feeds.value =
_feeds.value
.map {
if (it.id == id) it.copy(pinned = true, pinOrder = currentPinned) else it
}.toImmutableList()
return true
}
suspend fun unpin(id: String) {
_feeds.value =
_feeds.value
.map { if (it.id == id) it.copy(pinned = false, pinOrder = Int.MAX_VALUE) else it }
.toImmutableList()
// Reindex remaining pinned
reindexPinned()
}
suspend fun reorderPinned(
fromIndex: Int,
toIndex: Int,
) {
val pinned =
_feeds.value
.filter { it.pinned }
.sortedBy { it.pinOrder }
.toMutableList()
if (fromIndex !in pinned.indices || toIndex !in pinned.indices) return
val item = pinned.removeAt(fromIndex)
pinned.add(toIndex, item)
val reindexed = pinned.mapIndexed { i, feed -> feed.copy(pinOrder = i) }.associateBy { it.id }
_feeds.value =
_feeds.value
.map { reindexed[it.id] ?: it }
.toImmutableList()
}
private fun reindexPinned() {
val pinned = _feeds.value.filter { it.pinned }.sortedBy { it.pinOrder }
val reindexed = pinned.mapIndexed { i, feed -> feed.copy(pinOrder = i) }.associateBy { it.id }
_feeds.value =
_feeds.value
.map { reindexed[it.id] ?: it }
.toImmutableList()
}
}
sealed interface FeedEvent {
data class Created(
val feed: FeedDefinition,
) : FeedEvent
data class PinLimitReached(
val max: Int,
) : FeedEvent
}
@Immutable
data class GroupedFeeds(
val pinned: ImmutableList<FeedDefinition>,
val myFeeds: ImmutableList<FeedDefinition>,
val algoFeeds: ImmutableList<FeedDefinition>,
) {
companion object {
val EMPTY = GroupedFeeds(persistentListOf(), persistentListOf(), persistentListOf())
}
}
@@ -0,0 +1,191 @@
/*
* 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.feeds.custom
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.node.ArrayNode
import com.fasterxml.jackson.databind.node.ObjectNode
import kotlinx.collections.immutable.toImmutableList
object FeedDefinitionSerializer {
private val mapper = ObjectMapper()
fun serializeList(feeds: List<FeedDefinition>): String {
val array = mapper.createArrayNode()
feeds.forEach { feed -> array.add(serializeFeed(feed)) }
return mapper.writeValueAsString(array)
}
fun deserializeList(json: String): List<FeedDefinition> {
if (json.isBlank()) return emptyList()
val array = mapper.readTree(json) as? ArrayNode ?: return emptyList()
return array.mapNotNull { node -> deserializeFeed(node) }
}
private fun serializeFeed(feed: FeedDefinition): ObjectNode =
mapper.createObjectNode().apply {
put("id", feed.id)
put("name", feed.name)
put("emoji", feed.emoji)
put("pinned", feed.pinned)
put("pinOrder", feed.pinOrder)
put("refreshMode", feed.refreshMode.name)
put("createdAt", feed.createdAt)
set<ObjectNode>("source", serializeSource(feed.source))
}
private fun deserializeFeed(node: JsonNode): FeedDefinition? {
val id = node.get("id")?.asText() ?: return null
val name = node.get("name")?.asText() ?: return null
val emoji = node.get("emoji")?.asText() ?: ""
val pinned = node.get("pinned")?.asBoolean() ?: false
val pinOrder = node.get("pinOrder")?.asInt() ?: Int.MAX_VALUE
val refreshMode =
node.get("refreshMode")?.asText()?.let {
try {
RefreshMode.valueOf(it)
} catch (_: Exception) {
RefreshMode.LIVE_STREAM
}
} ?: RefreshMode.LIVE_STREAM
val createdAt = node.get("createdAt")?.asLong() ?: 0L
val source = node.get("source")?.let { deserializeSource(it) } ?: return null
return FeedDefinition(
id = id,
name = name,
emoji = emoji,
pinned = pinned,
pinOrder = pinOrder,
source = source,
refreshMode = refreshMode,
createdAt = createdAt,
)
}
private fun serializeSource(source: FeedSource): ObjectNode =
mapper.createObjectNode().apply {
when (source) {
is FeedSource.Filter -> {
put("type", "filter")
set<ArrayNode>("hashtags", mapper.valueToTree(source.hashtags.toList()))
set<ArrayNode>("authors", mapper.valueToTree(source.authors.toList()))
set<ArrayNode>("relays", mapper.valueToTree(source.relays.toList()))
set<ArrayNode>("excludeAuthors", mapper.valueToTree(source.excludeAuthors.toList()))
set<ArrayNode>("excludeKeywords", mapper.valueToTree(source.excludeKeywords.toList()))
set<ArrayNode>("kinds", mapper.valueToTree(source.kinds.toList()))
}
is FeedSource.PeopleList -> {
put("type", "people_list")
put("kind", source.kind)
put("pubkey", source.pubkey)
put("dTag", source.dTag)
}
is FeedSource.InterestSet -> {
put("type", "interest_set")
put("kind", source.kind)
put("pubkey", source.pubkey)
put("dTag", source.dTag)
}
is FeedSource.DVM -> {
put("type", "dvm")
put("kind", source.kind)
put("pubkey", source.pubkey)
put("dTag", source.dTag)
}
is FeedSource.SingleRelay -> {
put("type", "single_relay")
put("url", source.url)
}
FeedSource.Global -> {
put("type", "global")
}
FeedSource.Following -> {
put("type", "following")
}
}
}
private fun deserializeSource(node: JsonNode): FeedSource? {
val type = node.get("type")?.asText() ?: return null
return when (type) {
"filter" -> {
FeedSource.Filter(
hashtags = node.get("hashtags")?.map { it.asText() }?.toImmutableList() ?: return null,
authors = node.get("authors")?.map { it.asText() }?.toImmutableList() ?: return null,
relays = node.get("relays")?.map { it.asText() }?.toImmutableList() ?: return null,
excludeAuthors = node.get("excludeAuthors")?.map { it.asText() }?.toImmutableList() ?: return null,
excludeKeywords = node.get("excludeKeywords")?.map { it.asText() }?.toImmutableList() ?: return null,
kinds = node.get("kinds")?.map { it.asInt() }?.toImmutableList() ?: return null,
)
}
"people_list" -> {
FeedSource.PeopleList(
kind = node.get("kind")?.asInt() ?: 30000,
pubkey = node.get("pubkey")?.asText() ?: return null,
dTag = node.get("dTag")?.asText() ?: return null,
)
}
"interest_set" -> {
FeedSource.InterestSet(
kind = node.get("kind")?.asInt() ?: 30015,
pubkey = node.get("pubkey")?.asText() ?: return null,
dTag = node.get("dTag")?.asText() ?: return null,
)
}
"dvm" -> {
FeedSource.DVM(
kind = node.get("kind")?.asInt() ?: 31990,
pubkey = node.get("pubkey")?.asText() ?: return null,
dTag = node.get("dTag")?.asText() ?: return null,
)
}
"single_relay" -> {
FeedSource.SingleRelay(
url = node.get("url")?.asText() ?: return null,
)
}
"global" -> {
FeedSource.Global
}
"following" -> {
FeedSource.Following
}
else -> {
null
}
}
}
}
@@ -0,0 +1,40 @@
/*
* 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.feeds.custom
import com.vitorpamplona.amethyst.commons.search.SearchQuery
fun SearchQuery.toFeedDefinition(
name: String,
emoji: String = "",
): FeedDefinition =
feedDefinition {
this.name = name
this.emoji = emoji
filter {
hashtags += this@toFeedDefinition.hashtags
authors += this@toFeedDefinition.authors
excludeKeywords += this@toFeedDefinition.excludeTerms
kinds += this@toFeedDefinition.kinds
}
}
fun SearchQuery.canBecomeFeed(): Boolean = hashtags.isNotEmpty() || authors.isNotEmpty() || kinds.isNotEmpty()
@@ -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.search
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
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
/**
* Delegate for searching users via relays (NIP-50 or other mechanism).
* Platform-specific: desktop uses relay manager, Android could use its own.
*/
interface RelayUserSearchDelegate {
fun searchPeople(
query: String,
limit: Int,
onResult: (User) -> Unit,
onComplete: () -> Unit,
): Job
}
/**
* Reusable user search engine that combines local cache search with optional relay search.
* Platform-agnostic — lives in commons, relay interaction via delegate.
*
* Usage:
* ```
* val engine = UserSearchEngine(cache, scope)
* engine.relayDelegate = myRelayDelegate // optional
* engine.search("fiatjaf")
* // collect engine.results, engine.isSearching
* ```
*/
class UserSearchEngine(
private val cache: ICacheProvider,
private val scope: CoroutineScope,
private val debounceMs: Long = 300L,
private val localLimit: Int = 10,
private val relayLimit: Int = 10,
) {
private val _query = MutableStateFlow("")
val query: StateFlow<String> = _query.asStateFlow()
private val _localResults = MutableStateFlow<List<User>>(emptyList())
val localResults: StateFlow<List<User>> = _localResults.asStateFlow()
private val _relayResults = MutableStateFlow<List<User>>(emptyList())
val relayResults: StateFlow<List<User>> = _relayResults.asStateFlow()
private val _isSearching = MutableStateFlow(false)
val isSearching: StateFlow<Boolean> = _isSearching.asStateFlow()
var relayDelegate: RelayUserSearchDelegate? = null
private var relaySearchJob: Job? = null
init {
setupDebouncedSearch()
}
fun search(text: String) {
_query.value = text
_relayResults.value = emptyList()
relaySearchJob?.cancel()
if (text.length < 2) {
_localResults.value = emptyList()
_isSearching.value = false
}
}
fun clear() = search("")
/**
* Resolves an input string to a hex pubkey.
* Handles npub, nprofile, and raw hex.
*/
fun resolveToHex(input: String): String = decodePublicKeyAsHexOrNull(input.trim()) ?: input.trim()
@OptIn(FlowPreview::class)
private fun setupDebouncedSearch() {
_query
.debounce(debounceMs)
.onEach { text ->
if (text.length >= 2) {
// Local cache search (instant)
_localResults.value = cache.findUsersStartingWith(text, localLimit)
// Relay search (async, via delegate)
startRelaySearch(text)
} else {
_localResults.value = emptyList()
_isSearching.value = false
}
}.launchIn(scope)
}
private fun startRelaySearch(text: String) {
val delegate = relayDelegate ?: return
relaySearchJob?.cancel()
_isSearching.value = true
_relayResults.value = emptyList()
relaySearchJob =
delegate.searchPeople(
query = text,
limit = relayLimit,
onResult = { user ->
// Deduplicate against local results and existing relay results
val isDuplicate =
_localResults.value.any { it.pubkeyHex == user.pubkeyHex } ||
_relayResults.value.any { it.pubkeyHex == user.pubkeyHex }
if (!isDuplicate) {
_relayResults.value = _relayResults.value + user
}
},
onComplete = {
_isSearching.value = false
},
)
}
}
@@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
@@ -66,6 +67,11 @@ fun LoadingState(
/**
* A centered empty state with title, optional description, and optional refresh action.
*
* The optional `description` is wrapped in a [SelectionContainer] so users can
* select and copy it. `EmptyState` is reused as the in-feed error renderer
* (e.g. "Error loading feed" with the underlying error in `description`), so
* making the description selectable lets users copy error text for reporting.
*/
@Composable
fun EmptyState(
@@ -89,11 +95,13 @@ fun EmptyState(
)
if (description != null) {
Spacer(Modifier.height(8.dp))
Text(
description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
)
SelectionContainer {
Text(
description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
)
}
}
if (onRefresh != null) {
Spacer(Modifier.height(16.dp))
@@ -106,6 +114,9 @@ fun EmptyState(
/**
* A centered error state with message and optional retry action.
*
* The error `message` is wrapped in a [SelectionContainer] so users can select
* and copy it — useful for reporting bugs or pasting error text into a search.
*/
@Composable
fun ErrorState(
@@ -121,11 +132,13 @@ fun ErrorState(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
SelectionContainer {
Text(
message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
}
if (onRetry != null) {
Spacer(Modifier.height(16.dp))
Button(onClick = onRetry) {
@@ -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.feeds.custom
import kotlinx.collections.immutable.persistentListOf
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class FeedDefinitionSerializerTest {
@Test
fun roundTripFilterSource() {
val feed =
FeedDefinition(
id = "test-1",
name = "Bitcoin",
emoji = "\u20BF",
pinned = true,
pinOrder = 0,
source =
FeedSource.Filter(
hashtags = persistentListOf("bitcoin", "btc"),
authors = persistentListOf("abc123"),
relays = persistentListOf("wss://relay.damus.io"),
excludeAuthors = persistentListOf("spammer"),
excludeKeywords = persistentListOf("scam"),
kinds = persistentListOf(1, 6),
),
refreshMode = RefreshMode.LIVE_STREAM,
createdAt = 1000L,
)
val json = FeedDefinitionSerializer.serializeList(listOf(feed))
val deserialized = FeedDefinitionSerializer.deserializeList(json)
assertEquals(1, deserialized.size)
assertEquals(feed, deserialized[0])
}
@Test
fun roundTripGlobalSource() {
val feed =
FeedDefinition(
id = "test-global",
name = "Global",
emoji = "\uD83C\uDF10",
pinned = true,
pinOrder = 1,
source = FeedSource.Global,
refreshMode = RefreshMode.LIVE_STREAM,
createdAt = 2000L,
)
val json = FeedDefinitionSerializer.serializeList(listOf(feed))
val deserialized = FeedDefinitionSerializer.deserializeList(json)
assertEquals(1, deserialized.size)
assertEquals(feed, deserialized[0])
}
@Test
fun roundTripFollowingSource() {
val feed =
FeedDefinition(
id = "test-following",
name = "Following",
emoji = "\uD83C\uDFE0",
pinned = false,
pinOrder = Int.MAX_VALUE,
source = FeedSource.Following,
refreshMode = RefreshMode.LIVE_STREAM,
createdAt = 3000L,
)
val json = FeedDefinitionSerializer.serializeList(listOf(feed))
val deserialized = FeedDefinitionSerializer.deserializeList(json)
assertEquals(feed, deserialized[0])
}
@Test
fun roundTripDvmSource() {
val feed =
FeedDefinition(
id = "test-dvm",
name = "Trending",
emoji = "\uD83D\uDD25",
pinned = true,
pinOrder = 2,
source = FeedSource.DVM(kind = 31990, pubkey = "dvmpub123", dTag = "trending"),
refreshMode = RefreshMode.POLL_5MIN,
createdAt = 4000L,
)
val json = FeedDefinitionSerializer.serializeList(listOf(feed))
val deserialized = FeedDefinitionSerializer.deserializeList(json)
assertEquals(feed, deserialized[0])
}
@Test
fun roundTripPeopleListSource() {
val feed =
FeedDefinition(
id = "test-people",
name = "Dev Friends",
emoji = "\uD83D\uDC65",
pinned = false,
pinOrder = Int.MAX_VALUE,
source = FeedSource.PeopleList(kind = 30000, pubkey = "mypub", dTag = "devs"),
refreshMode = RefreshMode.LIVE_STREAM,
createdAt = 5000L,
)
val json = FeedDefinitionSerializer.serializeList(listOf(feed))
val deserialized = FeedDefinitionSerializer.deserializeList(json)
assertEquals(feed, deserialized[0])
}
@Test
fun roundTripInterestSetSource() {
val feed =
FeedDefinition(
id = "test-interest",
name = "Nostr Dev",
emoji = "\uD83D\uDEE0",
pinned = false,
pinOrder = Int.MAX_VALUE,
source = FeedSource.InterestSet(kind = 30015, pubkey = "mypub", dTag = "nostrdev"),
refreshMode = RefreshMode.LIVE_STREAM,
createdAt = 6000L,
)
val json = FeedDefinitionSerializer.serializeList(listOf(feed))
val deserialized = FeedDefinitionSerializer.deserializeList(json)
assertEquals(feed, deserialized[0])
}
@Test
fun roundTripSingleRelaySource() {
val feed =
FeedDefinition(
id = "test-relay",
name = "Damus Relay",
emoji = "\uD83D\uDCE1",
pinned = false,
pinOrder = Int.MAX_VALUE,
source = FeedSource.SingleRelay(url = "wss://relay.damus.io"),
refreshMode = RefreshMode.LIVE_STREAM,
createdAt = 7000L,
)
val json = FeedDefinitionSerializer.serializeList(listOf(feed))
val deserialized = FeedDefinitionSerializer.deserializeList(json)
assertEquals(feed, deserialized[0])
}
@Test
fun multipleFeeds() {
val feeds =
listOf(
feedDefinition {
name = "Bitcoin"
emoji = "\u20BF"
filter {
hashtags += "bitcoin"
kinds += 1
}
},
feedDefinition {
name = "Lightning"
emoji = "\u26A1"
filter {
hashtags += "lightning"
hashtags += "ln"
}
},
)
val json = FeedDefinitionSerializer.serializeList(feeds)
val deserialized = FeedDefinitionSerializer.deserializeList(json)
assertEquals(2, deserialized.size)
assertEquals("Bitcoin", deserialized[0].name)
assertEquals("Lightning", deserialized[1].name)
}
@Test
fun emptyJsonReturnsEmptyList() {
assertEquals(emptyList(), FeedDefinitionSerializer.deserializeList(""))
assertEquals(emptyList(), FeedDefinitionSerializer.deserializeList(" "))
}
@Test
fun defaultFeedsAreValid() {
val defaults = defaultFeeds()
assertEquals(2, defaults.size)
assertTrue(defaults[0].pinned)
assertTrue(defaults[1].pinned)
assertEquals(FeedSource.Following, defaults[0].source)
assertEquals(FeedSource.Global, defaults[1].source)
}
}
@@ -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.feeds.custom
import com.vitorpamplona.amethyst.commons.search.SearchQuery
import kotlinx.collections.immutable.persistentListOf
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class SearchQueryToFeedTest {
@Test
fun convertsHashtagsToFeedFilter() {
val query =
SearchQuery(
hashtags = persistentListOf("bitcoin", "nostr"),
)
val feed = query.toFeedDefinition(name = "BTC + Nostr", emoji = "\u20BF")
assertEquals("BTC + Nostr", feed.name)
assertEquals("\u20BF", feed.emoji)
val source = feed.source as FeedSource.Filter
assertEquals(listOf("bitcoin", "nostr"), source.hashtags)
}
@Test
fun convertsAuthorsToFeedFilter() {
val query =
SearchQuery(
authors = persistentListOf("abc123", "def456"),
)
val feed = query.toFeedDefinition(name = "Devs")
val source = feed.source as FeedSource.Filter
assertEquals(listOf("abc123", "def456"), source.authors)
}
@Test
fun convertsExcludeTerms() {
val query =
SearchQuery(
hashtags = persistentListOf("bitcoin"),
excludeTerms = persistentListOf("scam", "spam"),
)
val feed = query.toFeedDefinition(name = "Clean BTC")
val source = feed.source as FeedSource.Filter
assertEquals(listOf("scam", "spam"), source.excludeKeywords)
}
@Test
fun convertsKinds() {
val query =
SearchQuery(
kinds = persistentListOf(1, 30023),
hashtags = persistentListOf("dev"),
)
val feed = query.toFeedDefinition(name = "Dev Articles")
val source = feed.source as FeedSource.Filter
assertEquals(listOf(1, 30023), source.kinds)
}
@Test
fun canBecomeFeedWithHashtags() {
assertTrue(SearchQuery(hashtags = persistentListOf("btc")).canBecomeFeed())
}
@Test
fun canBecomeFeedWithAuthors() {
assertTrue(SearchQuery(authors = persistentListOf("abc")).canBecomeFeed())
}
@Test
fun canNotBecomeFeedEmpty() {
assertFalse(SearchQuery.EMPTY.canBecomeFeed())
}
@Test
fun canNotBecomeFeedTextOnly() {
assertFalse(SearchQuery(text = "hello").canBecomeFeed())
}
}