Merge branch 'main' into claude/add-blossom-cache-support-ts8mK
This commit is contained in:
+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.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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+88
@@ -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,
|
||||
}
|
||||
+125
@@ -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,
|
||||
),
|
||||
)
|
||||
+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.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())
|
||||
}
|
||||
}
|
||||
+191
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -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()
|
||||
+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.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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+23
-10
@@ -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) {
|
||||
|
||||
+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.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)
|
||||
}
|
||||
}
|
||||
+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.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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user