- Refactors the entire EOSE caching structure.

- Refactors all Datasource classes into a 3 layer design with composeSubscriptions being registered, passed to a group of EOSE managers that can change eose caching capabilities based on needs, and migrates the old subscription Orchestrator into a simpler controller
- Breaks down all Datasource into multiple filter functions
- Improves EOSE support for many of the base filters like on NIP-04 DMs, notifications, etc
- Moves EOSE cache for public, non-user-dependent items like channels, hashtags, etc to their own caching system.
- Improves the Relay speed logger to better track how many events are being received, and check for duplications
This commit is contained in:
Vitor Pamplona
2025-05-13 14:52:26 -04:00
parent 0e68031227
commit 6ecb25b8e1
154 changed files with 7891 additions and 3152 deletions
@@ -878,15 +878,17 @@ class Account(
}
val liveHomeListAuthorsPerRelay: StateFlow<Map<String, List<HexKey>>?> by lazy {
liveHomeListAuthorsPerRelayFlow.flowOn(Dispatchers.Default).stateIn(
scope,
SharingStarted.Eagerly,
authorsPerRelay(
liveHomeFollowLists.value?.authorsPlusMe?.map { getNIP65RelayListNote(it) } ?: emptyList(),
connectToRelays.value.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url },
settings.torSettings.torType.value,
).ifEmpty { null },
)
liveHomeListAuthorsPerRelayFlow
.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
authorsPerRelay(
liveHomeFollowLists.value?.authorsPlusMe?.map { getNIP65RelayListNote(it) } ?: emptyList(),
connectToRelays.value.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url },
settings.torSettings.torType.value,
).ifEmpty { null },
)
}
val liveNotificationFollowLists: StateFlow<LiveFollowList?> by lazy {
@@ -1677,6 +1679,7 @@ class Account(
fun broadcast(note: Note) {
note.event?.let {
if (it is WrappedEvent && it.host != null) {
// download the event and send it.
it.host?.let {
Amethyst.instance.client.sendFilterAndStopOnFirstResponse(
filters =
@@ -25,12 +25,12 @@ import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.launchAndWaitAll
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji
import com.vitorpamplona.amethyst.service.relays.EOSERelayList
import com.vitorpamplona.amethyst.service.replace
import com.vitorpamplona.amethyst.tryAndWait
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.ammolite.relays.BundledUpdate
import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.quartz.experimental.bounties.addedRewardValue
import com.vitorpamplona.quartz.experimental.bounties.hasAdditionalReward
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
@@ -162,7 +162,7 @@ open class Note(
var relays = listOf<RelayBriefInfoCache.RelayBriefInfo>()
private set
var lastReactionsDownloadTime: Map<String, EOSETime> = emptyMap()
var lastReactionsDownloadTime: EOSERelayList = EOSERelayList()
fun id() = Hex.decode(idHex)
@@ -298,7 +298,7 @@ open class Note(
zapPayments = mapOf<Note, Note?>()
zapsAmount = BigDecimal.ZERO
relays = listOf<RelayBriefInfoCache.RelayBriefInfo>()
lastReactionsDownloadTime = emptyMap()
lastReactionsDownloadTime.clear()
if (repliesChanged) flowSet?.replies?.invalidateData()
if (reactionsChanged) flowSet?.reactions?.invalidateData()
@@ -831,7 +831,7 @@ open class Note(
}
fun clearEOSE() {
lastReactionsDownloadTime = emptyMap()
lastReactionsDownloadTime.clear()
}
fun isHiddenFor(accountChoices: Account.LiveHiddenUsers): Boolean {
@@ -57,14 +57,13 @@ class ThreadAssembler {
// recursive
val roots =
note.replyTo
?.map {
if (it !in testedNotes) {
searchRoot(it, testedNotes)
} else {
null
}
}?.filterNotNull()
note.replyTo?.mapNotNull {
if (it !in testedNotes) {
searchRoot(it, testedNotes)
} else {
null
}
}
if (roots != null && roots.isNotEmpty()) {
return roots[0]
@@ -79,6 +78,18 @@ class ThreadAssembler {
val allNotes: ImmutableSet<Note>,
)
fun findRoot(noteId: String): Note? {
val note = LocalCache.checkGetOrCreateNote(noteId) ?: return null
return if (note.event != null) {
val thread = OnlyLatestVersionSet()
searchRoot(note, thread) ?: note
} else {
note
}
}
fun findThreadFor(noteId: String): ThreadInfo? {
checkNotInMainThread()
@@ -50,9 +50,5 @@ class CacheClientConnector(
private fun markAsSeen(
eventId: HexKey,
relay: RelayBriefInfoCache.RelayBriefInfo,
) {
val note = LocalCache.getNoteIfExists(eventId)
note?.addRelay(relay)
}
) = LocalCache.getNoteIfExists(eventId)?.addRelay(relay)
}
@@ -22,14 +22,14 @@ package com.vitorpamplona.amethyst.service.relayClient
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.QueryBasedSubscriptionOrchestrator
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.MutableQueryBasedSubscriptionOrchestrator
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.MutableQueryState
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.MutableComposeSubscriptionManager
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.MutableQueryState
@Composable
fun <T> KeyDataSourceSubscription(
state: T,
dataSource: QueryBasedSubscriptionOrchestrator<T>,
dataSource: ComposeSubscriptionManager<T>,
) = DisposableEffect(state) {
dataSource.subscribe(state)
onDispose {
@@ -40,7 +40,7 @@ fun <T> KeyDataSourceSubscription(
@Composable
fun <T : MutableQueryState> KeyDataSourceSubscription(
state: T,
dataSource: MutableQueryBasedSubscriptionOrchestrator<T>,
dataSource: MutableComposeSubscriptionManager<T>,
) = DisposableEffect(state) {
dataSource.subscribe(state)
onDispose {
@@ -21,10 +21,63 @@
package com.vitorpamplona.amethyst.service.relayClient
import android.util.Log
import com.vitorpamplona.amethyst.service.relayClient.RelaySpeedLogger.Companion.TAG
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.Relay
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.LargeCache
import java.util.concurrent.atomic.AtomicInteger
import kotlin.collections.forEach
import kotlin.collections.get
import kotlin.concurrent.timer
import kotlin.text.get
import kotlin.text.set
class FrameStat {
var eventCount = AtomicInteger(0)
var kinds = LargeCache<Int, AtomicInteger>()
fun increment(kind: Int) {
eventCount.incrementAndGet()
val kindCount = kinds.get(kind)
if (kindCount != null) {
kindCount.incrementAndGet()
} else {
kinds.put(kind, AtomicInteger(1))
}
}
fun hasAnything() = eventCount.get() > 0
fun reset() {
eventCount.set(0)
kinds.forEach { key, value ->
value.set(0)
}
}
fun log() {
Log.d(TAG, "Events Per Second: ${eventCount.get()}")
kinds.forEach { key, value ->
if (value.get() > 0) {
Log.d(TAG, "-- Events Per Second Debug: $key $value")
}
}
}
// Initialize the timer in the constructor. This ensures it starts when the
// ResettableCounter object is created.
init {
// Use a timer to reset the counter every second.
timer(name = "EventsPerSecondCounter", period = 1000, daemon = true) {
if (hasAnything()) {
log()
reset()
}
}
}
}
/**
* Listens to NostrClient's onNotify messages from the relay
@@ -36,8 +89,7 @@ class RelaySpeedLogger(
val TAG = RelaySpeedLogger::class.java.simpleName
}
var time = TimeUtils.now() / 10
var onEventCount: Long = 0L
var current = FrameStat()
private val clientListener =
object : NostrClient.Listener {
@@ -49,14 +101,7 @@ class RelaySpeedLogger(
arrivalTime: Long,
afterEOSE: Boolean,
) {
val now = TimeUtils.now() / 10
if (time == now) {
onEventCount++
} else {
Log.d(TAG, "EPS: ${onEventCount / 10} ")
time = now
onEventCount = 0
}
current.increment(event.kind)
}
}
@@ -18,56 +18,49 @@
* 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.service.relayClient.reqCommand
package com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.datasources.SubscriptionOrchestrator
import java.util.concurrent.ConcurrentHashMap
/**
* Creates a data source where the filters are derived from
* data (keys) that is being watched in the UI.
* 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 QueryBasedSubscriptionOrchestrator<T>(
client: NostrClient,
) : SubscriptionOrchestrator(client) {
private var queries: ConcurrentHashMap<T, T> = ConcurrentHashMap()
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
val wasEmpty = queries.isEmpty()
val wasEmpty = composeSubscriptions.isEmpty()
queries.put(query, query)
composeSubscriptions.put(query, query)
if (wasEmpty) {
start()
}
invalidateFilters()
invalidateKeys()
}
// This is called by main. Keep it really fast.
fun unsubscribe(query: T?) {
if (query == null) return
queries.remove(query)
composeSubscriptions.remove(query)
invalidateFilters()
invalidateKeys()
if (queries.isEmpty()) {
if (composeSubscriptions.isEmpty()) {
stop()
}
}
fun allKeys() = composeSubscriptions.keys
fun forEachSubscriber(action: (T) -> Unit) {
queries.keys.forEach(action)
composeSubscriptions.keys.forEach(action)
}
final override fun updateSubscriptions() {
updateSubscriptions(queries.keys)
}
abstract fun updateSubscriptions(keys: Set<T>)
}
@@ -0,0 +1,35 @@
/**
* Copyright (c) 2024 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.service.relayClient.composeSubscriptionManagers
interface ComposeSubscriptionManagerControls {
fun start()
fun stop()
fun invalidateKeys()
fun invalidateFilters()
fun destroy()
fun printStats()
}
@@ -18,10 +18,8 @@
* 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.service.relayClient.searchCommand
package com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.datasources.SubscriptionOrchestrator
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
@@ -29,31 +27,31 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
interface MutableQueryState {
fun flow(): Flow<*>
}
/**
* Creates a data source where the filters are derived from
* data (keys) that is being watched in the UI.
* 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 MutableQueryBasedSubscriptionOrchestrator<T : MutableQueryState>(
client: NostrClient,
abstract class MutableComposeSubscriptionManager<T : MutableQueryState>(
val scope: CoroutineScope,
) : SubscriptionOrchestrator(client) {
private var queries: ConcurrentHashMap<T, Job?> = ConcurrentHashMap()
) : 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
val wasEmpty = queries.isEmpty()
val wasEmpty = composeSubscriptions.isEmpty()
queries[query]?.cancel()
queries[query] =
composeSubscriptions[query]?.cancel()
composeSubscriptions[query] =
scope.launch {
query.flow().collectLatest {
invalidateFilters()
invalidateKeys()
}
}
@@ -61,30 +59,30 @@ abstract class MutableQueryBasedSubscriptionOrchestrator<T : MutableQueryState>(
start()
}
invalidateFilters()
invalidateKeys()
}
// This is called by main. Keep it really fast.
fun unsubscribe(query: T?) {
if (query == null) return
queries[query]?.cancel()
queries.remove(query)
composeSubscriptions[query]?.cancel()
composeSubscriptions.remove(query)
invalidateFilters()
invalidateKeys()
if (queries.isEmpty()) {
if (composeSubscriptions.isEmpty()) {
stop()
}
}
fun allKeys() = composeSubscriptions.keys
fun forEachSubscriber(action: (T) -> Unit) {
queries.keys.forEach(action)
composeSubscriptions.keys.forEach(action)
}
final override fun updateSubscriptions() {
updateSubscriptions(queries.keys)
}
abstract fun updateSubscriptions(keys: Set<T>)
}
interface MutableQueryState {
fun flow(): Flow<*>
}
@@ -0,0 +1,46 @@
/**
* Copyright (c) 2024 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.service.relayClient.eoseManagers
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.datasources.SubscriptionController
abstract class BaseEoseManager<T>(
val client: NostrClient,
val allKeys: () -> Set<T>,
) {
val orchestrator =
SubscriptionController(client) {
updateSubscriptions(allKeys())
}
abstract fun updateSubscriptions(keys: Set<T>)
fun printStats() = orchestrator.printStats(this.javaClass.simpleName)
fun invalidateFilters() = orchestrator.invalidateFilters()
fun start() = orchestrator.start()
fun stop() = orchestrator.stop()
fun destroy() = orchestrator.destroy()
}
@@ -0,0 +1,109 @@
/**
* Copyright (c) 2024 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.service.relayClient.eoseManagers
import com.vitorpamplona.amethyst.service.relays.EOSEFollowList
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.datasources.Subscription
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import kotlin.collections.distinctBy
/**
* This query type creates a new relay subscription for every SubID.id()
* that is subscribed into the keys. It is ideal for screen loading that
* can be shared among multiple logged-in users.
*
* This class keeps EOSEs for each SubID.id() for as long as possible and
* shares all EOSEs among all users.
*/
abstract class PerUniqueIdEoseManager<T>(
client: NostrClient,
allKeys: () -> Set<T>,
val invalidateAfterEose: Boolean = false,
) : BaseEoseManager<T>(client, allKeys) {
// long term EOSE cache
private val latestEOSEs = EOSEFollowList()
// map between each query Id and each subscription id
private val userSubscriptionMap = mutableMapOf<String, String>()
fun since(key: T) = latestEOSEs.since(id(key))
fun newEose(
key: T,
relayUrl: String,
time: Long,
) {
latestEOSEs.newEose(id(key), relayUrl, time)
if (invalidateAfterEose) {
invalidateFilters()
}
}
open fun newSub(key: T): Subscription =
orchestrator.requestNewSubscription { time, relayUrl ->
newEose(key, relayUrl, time)
}
open fun endSub(
key: String,
subId: String,
) {
orchestrator.dismissSubscription(subId)
}
fun findOrCreateSubFor(key: T): Subscription {
val id = id(key)
var subId = userSubscriptionMap[id]
return if (subId == null) {
newSub(key).also { userSubscriptionMap[id] = it.id }
} else {
orchestrator.getSub(subId) ?: newSub(key).also { userSubscriptionMap[id] = it.id }
}
}
override fun updateSubscriptions(keys: Set<T>) {
val uniqueSubscribedAccounts = keys.distinctBy { id(it) }
val updated = mutableSetOf<String>()
uniqueSubscribedAccounts.forEach {
val mainKey = id(it)
findOrCreateSubFor(it).typedFilters = updateFilter(it, since(it))?.ifEmpty { null }
updated.add(mainKey)
}
userSubscriptionMap.forEach {
if (it.key !in updated) {
endSub(it.key, it.value)
}
}
}
abstract fun updateFilter(
key: T,
since: Map<String, EOSETime>?,
): List<TypedFilter>?
abstract fun id(key: T): String
}
@@ -0,0 +1,109 @@
/**
* Copyright (c) 2024 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.service.relayClient.eoseManagers
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.datasources.Subscription
import com.vitorpamplona.ammolite.relays.filters.EOSETime
/**
* This query type creates a new relay subscription for every logged-in
* user AND each follow list they select in the top of the screen.
*
* It is ideal for the home, video, discovery and notification screens
* since they all have lists to choose from.
*
* This class keeps EOSEs for each user & list for as long as possible and
* does NOT share EOSEs with other users. Changing the list will not make the
* app reuse the EOSE because it assumes the filter is going to be different
*/
abstract class PerUserAndFollowListEoseManager<T>(
client: NostrClient,
allKeys: () -> Set<T>,
val invalidateAfterEose: Boolean = false,
) : BaseEoseManager<T>(client, allKeys) {
private val latestEOSEs = EOSEAccount()
private val userSubscriptionMap = mutableMapOf<User, String>()
fun since(key: T) = latestEOSEs.since(user(key), list(key))
fun newEose(
key: T,
relayUrl: String,
time: Long,
) = latestEOSEs.newEose(user(key), list(key), relayUrl, time)
open fun newSub(key: T): Subscription =
orchestrator.requestNewSubscription { time, relayUrl ->
newEose(key, relayUrl, time)
if (invalidateAfterEose) {
invalidateFilters()
}
}
open fun endSub(
key: User,
subId: String,
) {
orchestrator.dismissSubscription(subId)
}
fun findOrCreateSubFor(key: T): Subscription {
val user = user(key)
var subId = userSubscriptionMap[user]
return if (subId == null) {
newSub(key).also { userSubscriptionMap[user] = it.id }
} else {
orchestrator.getSub(subId) ?: newSub(key).also { userSubscriptionMap[user] = it.id }
}
}
override fun updateSubscriptions(keys: Set<T>) {
val uniqueSubscribedAccounts = keys.distinctBy { user(it) }
val updated = mutableSetOf<User>()
uniqueSubscribedAccounts.forEach {
val user = user(it)
findOrCreateSubFor(it).typedFilters = updateFilter(it, since(it))?.ifEmpty { null }
updated.add(user)
}
userSubscriptionMap.forEach {
if (it.key !in updated) {
endSub(it.key, it.value)
}
}
}
abstract fun updateFilter(
key: T,
since: Map<String, EOSETime>?,
): List<TypedFilter>?
abstract fun user(key: T): User
abstract fun list(key: T): String
}
@@ -0,0 +1,106 @@
/**
* Copyright (c) 2024 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.service.relayClient.eoseManagers
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.datasources.Subscription
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import kotlin.collections.distinctBy
/**
* This query type creates a new relay subscription for every distinct
* user that is subscribed into the keys. It is ideal for screens that
* CANNOT be shared among multiple logged-in users.
*
* This class keeps EOSEs for each user for as long as possible and
* does NOT share EOSEs with other users. The EOSEs are kept even when
* the subscription disappears and comes back later.
*/
abstract class PerUserEoseManager<T>(
client: NostrClient,
allKeys: () -> Set<T>,
val invalidateAfterEose: Boolean = false,
) : BaseEoseManager<T>(client, allKeys) {
private val latestEOSEs = EOSEAccountFast()
private val userSubscriptionMap = mutableMapOf<User, String>()
fun since(key: T) = latestEOSEs.since(user(key))
fun newEose(
key: T,
relayUrl: String,
time: Long,
) = latestEOSEs.newEose(user(key), relayUrl, time)
open fun newSub(key: T): Subscription =
orchestrator.requestNewSubscription { time, relayUrl ->
newEose(key, relayUrl, time)
if (invalidateAfterEose) {
invalidateFilters()
}
}
open fun endSub(
key: User,
subId: String,
) {
orchestrator.dismissSubscription(subId)
}
fun findOrCreateSubFor(key: T): Subscription {
val user = user(key)
var subId = userSubscriptionMap[user]
return if (subId == null) {
newSub(key).also { userSubscriptionMap[user] = it.id }
} else {
orchestrator.getSub(subId) ?: newSub(key).also { userSubscriptionMap[user] = it.id }
}
}
override fun updateSubscriptions(keys: Set<T>) {
val uniqueSubscribedAccounts = keys.distinctBy { user(it) }
val updated = mutableSetOf<User>()
uniqueSubscribedAccounts.forEach {
val user = user(it)
findOrCreateSubFor(it).typedFilters = updateFilter(it, since(it))?.ifEmpty { null }
updated.add(user)
}
userSubscriptionMap.forEach {
if (it.key !in updated) {
endSub(it.key, it.value)
}
}
}
abstract fun updateFilter(
key: T,
since: Map<String, EOSETime>?,
): List<TypedFilter>?
abstract fun user(key: T): User
}
@@ -0,0 +1,76 @@
/**
* Copyright (c) 2024 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.service.relayClient.eoseManagers
import com.vitorpamplona.amethyst.service.relays.EOSERelayList
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import kotlin.collections.distinctBy
/**
* 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: NostrClient,
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(
relayUrl: String,
time: Long,
) = latestEOSEs.newEose(relayUrl, time)
val sub =
orchestrator.requestNewSubscription { time, relayUrl ->
newEose(relayUrl, time)
if (invalidateAfterEose) {
invalidateFilters()
}
}
override fun updateSubscriptions(keys: Set<T>) {
val uniqueSubscribedAccounts = keys.distinctBy { distinct(it) }
sub.typedFilters = updateFilter(uniqueSubscribedAccounts, since())?.ifEmpty { null }
}
abstract fun updateFilter(
key: List<T>,
since: Map<String, EOSETime>?,
): List<TypedFilter>?
abstract fun distinct(key: T): Any
}
@@ -21,8 +21,9 @@
package com.vitorpamplona.amethyst.service.relayClient.reqCommand
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManagerControls
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderFilterAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderFilterAssemblyGroup
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssembler
@@ -57,7 +58,7 @@ class RelaySubscriptionsCoordinator(
// loaders of content that is not yet in the device.
// they are active when looking at events, users, channels.
val channelFinder = ChannelFinderFilterAssembler(client)
val channelFinder = ChannelFinderFilterAssemblyGroup(client)
val eventFinder = EventFinderFilterAssembler(client)
val userFinder = UserFinderFilterAssembler(client)
@@ -77,7 +78,7 @@ class RelaySubscriptionsCoordinator(
val nwc = NWCPaymentFilterAssembler(client)
val all =
listOf(
listOf<ComposeSubscriptionManagerControls>(
account,
home,
chatroomList,
@@ -103,5 +104,5 @@ class RelaySubscriptionsCoordinator(
fun destroy() = all.forEach { it.destroy() }
fun printCounters() = all.forEach { it.stats.printCounter("${it.javaClass.simpleName}") }
fun printCounters() = all.forEach { it.printStats() }
}
@@ -21,302 +21,41 @@
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.QueryBasedSubscriptionOrchestrator
import com.vitorpamplona.amethyst.service.relays.EOSERelayList
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata.AccountMetadataEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.blossom.BlossomServersEvent
import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.FollowListEvent
import com.vitorpamplona.quartz.nip51Lists.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent
import com.vitorpamplona.quartz.nip52Calendar.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.CalendarRSVPEvent
import com.vitorpamplona.quartz.nip52Calendar.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent
import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent
import com.vitorpamplona.quartz.utils.TimeUtils
// This allows multiple screen to be listening to tags, even the same tag
// This allows multiple screen to be listening to logged-in accounts.
class AccountQueryState(
val account: Account,
val otherAccounts: Set<HexKey>,
)
/**
* This assembler loads everything eech account needs.
*/
class AccountFilterAssembler(
client: NostrClient,
) : QueryBasedSubscriptionOrchestrator<AccountQueryState>(client) {
val latestEOSE = EOSERelayList()
var hasLoadedTheBasics: Boolean = false
fun createAccountMetadataFilter(authorsHexes: List<String>): TypedFilter =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
MetadataEvent.KIND,
ContactListEvent.KIND,
StatusEvent.KIND,
AdvertisedRelayListEvent.KIND,
ChatMessageRelayListEvent.KIND,
SearchRelayListEvent.KIND,
FileServersEvent.KIND,
BlossomServersEvent.KIND,
PrivateOutboxRelayListEvent.KIND,
),
authors = authorsHexes,
limit = 20 * authorsHexes.size,
),
) : ComposeSubscriptionManager<AccountQueryState>() {
val group =
listOf(
AccountMetadataEoseManager(client, ::allKeys),
AccountGiftWrapsEoseManager(client, ::allKeys),
AccountNotificationsEoseManager(client, ::allKeys),
)
fun createOtherAccountsBaseFilter(otherAccounts: List<HexKey>): TypedFilter? {
if (otherAccounts.isEmpty()) return null
return TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
MetadataEvent.KIND,
ContactListEvent.KIND,
AdvertisedRelayListEvent.KIND,
ChatMessageRelayListEvent.KIND,
SearchRelayListEvent.KIND,
FileServersEvent.KIND,
BlossomServersEvent.KIND,
MuteListEvent.KIND,
PeopleListEvent.KIND,
),
authors = otherAccounts,
limit = otherAccounts.size * 20,
),
)
}
override fun start() = group.forEach { it.start() }
fun createAccountSettingsFilter(authorsHexes: List<String>): TypedFilter =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
PeopleListEvent.KIND,
FollowListEvent.KIND,
MuteListEvent.KIND,
BadgeProfilesEvent.KIND,
EmojiPackSelectionEvent.KIND,
EphemeralChatListEvent.KIND,
ChannelListEvent.KIND,
),
authors = authorsHexes,
limit = 100 * authorsHexes.size,
),
)
override fun stop() = group.forEach { it.stop() }
fun createAccountSettings2Filter(authorsHexes: List<String>): TypedFilter =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
AppSpecificDataEvent.KIND,
),
authors = authorsHexes,
tags = mapOf("d" to listOf(Account.APP_SPECIFIC_DATA_D_TAG)),
limit = 2 * authorsHexes.size,
),
)
override fun invalidateKeys() = invalidateFilters()
fun createAccountLastPostsListFilter(authorsHexes: List<String>): TypedFilter =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
authors = authorsHexes,
limit = 500,
),
)
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
fun createAccountReportsAndDraftsFilter(authorsHexes: List<String>): TypedFilter =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
DraftEvent.KIND,
ReportEvent.KIND,
BookmarkListEvent.KIND,
),
authors = authorsHexes,
since = latestEOSE.relayList,
),
)
override fun destroy() = group.forEach { it.start() }
fun createGiftWrapsToMeFilter(authorsHexes: List<String>) =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(GiftWrapEvent.KIND),
tags = mapOf("p" to authorsHexes),
since =
latestEOSE.relayList.mapValues {
EOSETime(it.value.time - TimeUtils.twoDays())
},
),
)
fun createNotificationFilter(authorsHexes: List<String>) =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
ReactionEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
ReportEvent.KIND,
LnZapEvent.KIND,
LnZapPaymentResponseEvent.KIND,
ChannelMessageEvent.KIND,
EphemeralChatEvent.KIND,
BadgeAwardEvent.KIND,
),
tags = mapOf("p" to authorsHexes),
limit = 4000,
since = latestEOSE.relayList,
),
)
fun createNotificationFilter2(authorsHexes: List<String>) =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
GitReplyEvent.KIND,
GitIssueEvent.KIND,
GitPatchEvent.KIND,
HighlightEvent.KIND,
CommentEvent.KIND,
CalendarDateSlotEvent.KIND,
CalendarTimeSlotEvent.KIND,
CalendarRSVPEvent.KIND,
InteractiveStoryPrologueEvent.KIND,
InteractiveStorySceneEvent.KIND,
),
tags = mapOf("p" to authorsHexes),
limit = 400,
since = latestEOSE.relayList,
),
)
fun createNotificationFilter3(authorsHexes: List<String>) =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
PollNoteEvent.KIND,
),
tags = mapOf("p" to authorsHexes),
limit = 100,
since = latestEOSE.relayList,
),
)
fun mergeAllFilters(
mainAccounts: List<HexKey>,
otherAccounts: List<HexKey>,
): List<TypedFilter>? =
if (hasLoadedTheBasics) {
// gets everything about the user logged in
listOfNotNull(
createAccountMetadataFilter(mainAccounts),
createAccountSettings2Filter(mainAccounts),
createNotificationFilter(mainAccounts),
createNotificationFilter2(mainAccounts),
createNotificationFilter3(mainAccounts),
createGiftWrapsToMeFilter(mainAccounts),
createAccountReportsAndDraftsFilter(mainAccounts),
createAccountSettingsFilter(mainAccounts),
createAccountLastPostsListFilter(mainAccounts),
createOtherAccountsBaseFilter(otherAccounts),
).ifEmpty { null }
} else {
// just the basics.
listOf(
createAccountMetadataFilter(mainAccounts),
createAccountSettingsFilter(mainAccounts),
createAccountSettings2Filter(mainAccounts),
).ifEmpty { null }
}
val accountChannel =
requestNewSubscription { time, relayUrl ->
if (hasLoadedTheBasics) {
latestEOSE.addOrUpdate(relayUrl, time)
} else {
hasLoadedTheBasics = true
invalidateFilters()
}
}
// One sub per subscribed account
override fun updateSubscriptions(keys: Set<AccountQueryState>) {
val mainAccounts = mutableSetOf<HexKey>()
val otherAccounts = mutableSetOf<HexKey>()
keys.forEach {
mainAccounts.add(it.account.userProfile().pubkeyHex)
otherAccounts.addAll(it.otherAccounts)
}
accountChannel.typedFilters = mergeAllFilters(mainAccounts.toList(), (otherAccounts - mainAccounts).toList())
}
override fun printStats() = group.forEach { it.printStats() }
}
@@ -0,0 +1,46 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.account.metadata
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
class AccountMetadataEoseManager(
client: NostrClient,
allKeys: () -> Set<AccountQueryState>,
) : PerUserEoseManager<AccountQueryState>(client, allKeys) {
override fun user(query: AccountQueryState) = query.account.userProfile()
override fun updateFilter(
key: AccountQueryState,
since: Map<String, EOSETime>?,
): List<TypedFilter>? =
listOfNotNull(
filterAccountInfoAndListsFromKey(user(key).pubkeyHex, since),
filterFollowsAndMutesFromKey(user(key).pubkeyHex, since),
filterDraftsAndReportsFromKey(user(key).pubkeyHex, since),
filterLastPostsFromKey(user(key).pubkeyHex, since),
filterBasicAccountInfoFromKeys(key.otherAccounts.minus(key.account.userProfile().pubkeyHex).toList(), since),
).flatten()
}
@@ -0,0 +1,80 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.account.metadata
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.blossom.BlossomServersEvent
import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent
fun filterAccountInfoAndListsFromKey(
pubkey: HexKey?,
since: Map<String, EOSETime>?,
): List<TypedFilter>? {
if (pubkey == null || pubkey.isEmpty()) return null
return listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
MetadataEvent.KIND,
ContactListEvent.KIND,
StatusEvent.KIND,
AdvertisedRelayListEvent.KIND,
ChatMessageRelayListEvent.KIND,
SearchRelayListEvent.KIND,
FileServersEvent.KIND,
BlossomServersEvent.KIND,
PrivateOutboxRelayListEvent.KIND,
),
authors = listOf(pubkey),
limit = 20,
since = since,
),
),
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(AppSpecificDataEvent.KIND),
authors = listOf(pubkey),
tags = mapOf("d" to listOf(Account.APP_SPECIFIC_DATA_D_TAG)),
limit = 1,
since = since,
),
),
)
}
@@ -0,0 +1,63 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.account.metadata
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.blossom.BlossomServersEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent
fun filterBasicAccountInfoFromKeys(
otherAccounts: List<HexKey>?,
since: Map<String, EOSETime>?,
): List<TypedFilter>? {
if (otherAccounts == null || otherAccounts.isEmpty()) return null
return listOf(
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
MetadataEvent.KIND,
ContactListEvent.KIND,
AdvertisedRelayListEvent.KIND,
ChatMessageRelayListEvent.KIND,
SearchRelayListEvent.KIND,
FileServersEvent.KIND,
BlossomServersEvent.KIND,
),
authors = otherAccounts.toList(),
limit = otherAccounts.size * 8,
since = since,
),
),
)
}
@@ -0,0 +1,54 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.account.metadata
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
fun filterDraftsAndReportsFromKey(
pubkey: HexKey?,
since: Map<String, EOSETime>?,
): List<TypedFilter>? {
if (pubkey == null || pubkey.isEmpty()) return null
return listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
DraftEvent.KIND,
ReportEvent.KIND,
BookmarkListEvent.KIND,
),
authors = listOf(pubkey),
since = since,
),
),
)
}
@@ -0,0 +1,63 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.account.metadata
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.nip51Lists.FollowListEvent
import com.vitorpamplona.quartz.nip51Lists.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent
import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent
fun filterFollowsAndMutesFromKey(
pubkey: HexKey?,
since: Map<String, EOSETime>?,
): List<TypedFilter>? {
if (pubkey == null || pubkey.isEmpty()) return null
return listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
PeopleListEvent.KIND,
FollowListEvent.KIND,
MuteListEvent.KIND,
BadgeProfilesEvent.KIND,
EmojiPackSelectionEvent.KIND,
EphemeralChatListEvent.KIND,
ChannelListEvent.KIND,
),
authors = listOf(pubkey),
limit = 100,
since = since,
),
),
)
}
@@ -0,0 +1,46 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.account.metadata
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip01Core.core.HexKey
fun filterLastPostsFromKey(
pubkey: HexKey?,
since: Map<String, EOSETime>?,
): List<TypedFilter>? {
if (pubkey == null || pubkey.isEmpty()) return null
return listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
authors = listOf(pubkey),
limit = 100,
since = since,
),
),
)
}
@@ -0,0 +1,39 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.account.nip01Notifications
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
class AccountNotificationsEoseManager(
client: NostrClient,
allKeys: () -> Set<AccountQueryState>,
) : PerUserEoseManager<AccountQueryState>(client, allKeys) {
override fun user(query: AccountQueryState) = query.account.userProfile()
override fun updateFilter(
key: AccountQueryState,
since: Map<String, EOSETime>?,
): List<TypedFilter>? = filterNotificationsToPubkey(user(key).pubkeyHex, since)
}
@@ -0,0 +1,112 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.account.nip01Notifications
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip52Calendar.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.CalendarRSVPEvent
import com.vitorpamplona.quartz.nip52Calendar.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
fun filterNotificationsToPubkey(
pubkey: HexKey?,
since: Map<String, EOSETime>?,
): List<TypedFilter>? {
if (pubkey == null || pubkey.isEmpty()) return null
return listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
ReactionEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
ReportEvent.KIND,
LnZapEvent.KIND,
LnZapPaymentResponseEvent.KIND,
ChannelMessageEvent.KIND,
EphemeralChatEvent.KIND,
BadgeAwardEvent.KIND,
),
tags = mapOf("p" to listOf(pubkey)),
limit = 2000,
since = since,
),
),
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
GitReplyEvent.KIND,
GitIssueEvent.KIND,
GitPatchEvent.KIND,
HighlightEvent.KIND,
CommentEvent.KIND,
CalendarDateSlotEvent.KIND,
CalendarTimeSlotEvent.KIND,
CalendarRSVPEvent.KIND,
InteractiveStoryPrologueEvent.KIND,
InteractiveStorySceneEvent.KIND,
),
tags = mapOf("p" to listOf(pubkey)),
limit = 400,
since = since,
),
),
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(PollNoteEvent.KIND),
tags = mapOf("p" to listOf(pubkey)),
limit = 100,
since = since,
),
),
)
}
@@ -0,0 +1,39 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.account.nip59GiftWraps
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
class AccountGiftWrapsEoseManager(
client: NostrClient,
allKeys: () -> Set<AccountQueryState>,
) : PerUserEoseManager<AccountQueryState>(client, allKeys) {
override fun user(query: AccountQueryState) = query.account.userProfile()
override fun updateFilter(
key: AccountQueryState,
since: Map<String, EOSETime>?,
): List<TypedFilter>? = filterGiftWrapsToPubkey(user(key).pubkeyHex, since)
}
@@ -0,0 +1,51 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.account.nip59GiftWraps
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.utils.TimeUtils
fun filterGiftWrapsToPubkey(
pubkey: HexKey?,
since: Map<String, EOSETime>?,
): List<TypedFilter>? {
if (pubkey == null || pubkey.isEmpty()) return null
return listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(GiftWrapEvent.KIND),
tags = mapOf("p" to listOf(pubkey)),
since =
since?.mapValues {
EOSETime(it.value.time - TimeUtils.twoDays())
},
),
),
)
}
@@ -1,131 +0,0 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.channel
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.QueryBasedSubscriptionOrchestrator
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.NormalFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
// This allows multiple screen to be listening to tags, even the same tag
class ChannelFinderQueryState(
val channel: Channel,
)
class ChannelFinderFilterAssembler(
client: NostrClient,
) : QueryBasedSubscriptionOrchestrator<ChannelFinderQueryState>(client) {
private fun createMetadataChangeFilter(keys: Set<ChannelFinderQueryState>): List<TypedFilter> {
val channelsToWatch = keys.filter { it.channel is PublicChatChannel }
if (channelsToWatch.isEmpty()) {
return emptyList()
}
// downloads all the reactions to a given event.
return channelsToWatch.map {
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
NormalFilter(
kinds = listOf(ChannelMetadataEvent.KIND),
tags = mapOf("e" to listOf(it.channel.idHex)),
since = it.channel.updatedMetadataAt + 1,
),
)
}
}
fun createLoadEventsIfNotLoadedFilter(keys: Set<ChannelFinderQueryState>): TypedFilter? {
val interestedEvents =
keys.mapNotNullTo(mutableSetOf()) {
if (it.channel is PublicChatChannel && it.channel.event == null) {
it.channel.idHex
} else {
null
}
}
if (interestedEvents.isEmpty()) {
return null
}
// downloads linked events to this event.
return TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(ChannelCreateEvent.KIND),
ids = interestedEvents.toList(),
),
)
}
fun createLoadStreamingIfNotLoadedFilter(keys: Set<ChannelFinderQueryState>): List<TypedFilter> {
val directEventsToLoad =
keys.mapNotNull {
if (it.channel is LiveActivitiesChannel && it.channel.info == null) {
it.channel
} else {
null
}
}
val interestedEvents = directEventsToLoad.map { it.idHex }.toSet()
if (interestedEvents.isEmpty()) {
return emptyList()
}
// downloads linked events to this event.
return directEventsToLoad.map {
it.address().let { aTag ->
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
authors = listOf(aTag.pubKeyHex),
),
)
}
}
}
val singleChannelChannel = requestNewSubscription()
override fun updateSubscriptions(keys: Set<ChannelFinderQueryState>) {
val missing = createLoadEventsIfNotLoadedFilter(keys)
val missingStreaming = createLoadStreamingIfNotLoadedFilter(keys)
val metadataWatcher = createMetadataChangeFilter(keys)
singleChannelChannel.typedFilters = (listOfNotNull(missing) + missingStreaming + metadataWatcher).ifEmpty { null }
}
}
@@ -0,0 +1,58 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.channel
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.mixChatsLive.ChannelMetadataAndLiveActivityWatcherSubAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats.ChannelLoaderSubAssembler
import com.vitorpamplona.ammolite.relays.NostrClient
// This allows multiple screen to be listening to tags, even the same tag
class ChannelFinderQueryState(
val channel: Channel,
)
class ChannelFinderFilterAssemblyGroup(
client: NostrClient,
) : ComposeSubscriptionManager<ChannelFinderQueryState>() {
val group =
listOf(
// this is a constantly rotating filter, better to keep it isolated
ChannelLoaderSubAssembler(client, ::allKeys),
// Later we could switch these and use one subscription for each query
// ChannelMetadataWatcherSubAssembler(client, ::allKeys),
// LiveActivityWatcherSubAssembly(client, ::allKeys)
ChannelMetadataAndLiveActivityWatcherSubAssembler(client, ::allKeys),
)
override fun start() = group.forEach { it.start() }
override fun stop() = group.forEach { it.stop() }
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun invalidateKeys() = invalidateFilters()
override fun destroy() = group.forEach { it.destroy() }
override fun printStats() = group.forEach { it.printStats() }
}
@@ -35,7 +35,7 @@ fun ChannelFinderFilterAssemblerSubscription(
@Composable
fun ChannelFinderFilterAssemblerSubscription(
channel: Channel,
dataSource: ChannelFinderFilterAssembler,
dataSource: ChannelFinderFilterAssemblyGroup,
) {
// different screens get different states
// even if they are tracking the same tag.
@@ -0,0 +1,60 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.channel.mixChatsLive
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats.filterChannelMetadataUpdatesById
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip53LiveActivities.filterLiveStreamUpdatesByAddress
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
/**
* This assembler observes modifications to the LiveActivity root events
* since they are replaceable and updates to the public chat metadata events.
*
* They are all cramped as multiple filters in a single subscription with
* only one EOSE for everybody.
*/
class ChannelMetadataAndLiveActivityWatcherSubAssembler(
client: NostrClient,
allKeys: () -> Set<ChannelFinderQueryState>,
) : SingleSubEoseManager<ChannelFinderQueryState>(client, allKeys) {
override fun updateFilter(
keys: List<ChannelFinderQueryState>,
since: Map<String, EOSETime>?,
): List<TypedFilter>? =
keys
.mapNotNull { key ->
if (key.channel is PublicChatChannel) {
filterChannelMetadataUpdatesById(key.channel, since)
} else if (key.channel is LiveActivitiesChannel) {
filterLiveStreamUpdatesByAddress(key.channel, since)
} else {
null
}
}.flatten()
override fun distinct(key: ChannelFinderQueryState) = key.channel.idHex
}
@@ -0,0 +1,48 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.channel.nip28PublicChats
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
/**
* This assembler observes loads missing public chats when needed.
*
* It is a fast rotating filter that tends to zero as new events arrive.
* Because of that, we isolate to avoid interrupting the EOSE of filters
* with long loading times.
*
* This is one filter for everybody.
*/
class ChannelLoaderSubAssembler(
client: NostrClient,
allKeys: () -> Set<ChannelFinderQueryState>,
) : SingleSubEoseManager<ChannelFinderQueryState>(client, allKeys, invalidateAfterEose = true) {
override fun updateFilter(
keys: List<ChannelFinderQueryState>,
since: Map<String, EOSETime>?,
): List<TypedFilter>? = filterMissingChannelsById(keys, since)
override fun distinct(key: ChannelFinderQueryState) = key.channel.idHex
}
@@ -0,0 +1,48 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.channel.nip28PublicChats
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
class ChannelMetadataWatcherSubAssembler(
client: NostrClient,
allKeys: () -> Set<ChannelFinderQueryState>,
) : PerUniqueIdEoseManager<ChannelFinderQueryState>(client, allKeys) {
override fun updateFilter(
key: ChannelFinderQueryState,
since: Map<String, EOSETime>?,
): List<TypedFilter>? =
if (key.channel is PublicChatChannel) {
filterChannelMetadataUpdatesById(key.channel, since)
} else {
null
}
/**
* Only one key per channel.
*/
override fun id(key: ChannelFinderQueryState) = key.channel.idHex
}
@@ -0,0 +1,56 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.channel.nip28PublicChats
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
fun filterMissingChannelsById(
keys: List<ChannelFinderQueryState>,
since: Map<String, EOSETime>?,
): List<TypedFilter>? {
val firstTimers =
keys.mapNotNullTo(mutableSetOf()) {
if (it.channel is PublicChatChannel && it.channel.event == null) {
it.channel.idHex
} else {
null
}
}
if (firstTimers.isEmpty()) return null
return listOf(
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(ChannelCreateEvent.KIND),
ids = firstTimers.toList(),
),
),
)
}
@@ -0,0 +1,44 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.channel.nip28PublicChats
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
fun filterChannelMetadataUpdatesById(
channel: PublicChatChannel,
since: Map<String, EOSETime>?,
): List<TypedFilter> =
listOf(
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
SincePerRelayFilter(
kinds = listOf(ChannelMetadataEvent.KIND),
tags = mapOf("e" to listOf(channel.idHex)),
since = since,
),
),
)
@@ -0,0 +1,46 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.channel.nip53LiveActivities
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
fun filterLiveStreamUpdatesByAddress(
channel: LiveActivitiesChannel,
since: Map<String, EOSETime>?,
): List<TypedFilter> {
val stream = channel.address()
return listOf(
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(stream.kind),
tags = mapOf("d" to listOf(stream.dTag)),
authors = listOf(stream.pubKeyHex),
since = since,
),
),
)
}
@@ -0,0 +1,52 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.channel.nip53LiveActivities
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
/**
* This assembler observes modifications to the LiveActivity root events
* since they are replaceable.
*/
class LiveActivityWatcherSubAssembly(
client: NostrClient,
allKeys: () -> Set<ChannelFinderQueryState>,
) : PerUniqueIdEoseManager<ChannelFinderQueryState>(client, allKeys) {
override fun updateFilter(
key: ChannelFinderQueryState,
since: Map<String, EOSETime>?,
): List<TypedFilter>? =
if (key.channel is LiveActivitiesChannel) {
filterLiveStreamUpdatesByAddress(key.channel, since)
} else {
null
}
/**
* Only one key per channel.
*/
override fun id(key: ChannelFinderQueryState) = key.channel.idHex
}
@@ -20,32 +20,11 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.QueryBasedSubscriptionOrchestrator
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.NoteEventLoaderSubAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers.EventWatcherSubAssembler
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent
// This allows multiple screen to be listening to tags, even the same tag
class EventFinderQueryState(
@@ -54,286 +33,22 @@ class EventFinderQueryState(
class EventFinderFilterAssembler(
client: NostrClient,
) : QueryBasedSubscriptionOrchestrator<EventFinderQueryState>(client) {
private fun createReactionsToWatchInAddressFilter(keys: Set<AddressableNote>): List<TypedFilter>? {
if (keys.isEmpty()) {
return null
}
return groupByEOSEPresence(keys)
.map {
listOf(
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
ReactionEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
ReportEvent.KIND,
LnZapEvent.KIND,
PollNoteEvent.KIND,
CommunityPostApprovalEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
),
tags = mapOf("a" to it.mapNotNull { it.address()?.toValue() }),
since = findMinimumEOSEs(it),
// Max amount of "replies" to download on a specific event.
limit = 1000,
),
),
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
DeletionEvent.KIND,
),
tags = mapOf("a" to it.mapNotNull { it.address()?.toValue() }),
since = findMinimumEOSEs(it),
// Max amount of "replies" to download on a specific event.
limit = 10,
),
),
)
}.flatten()
}
private fun createAddressFilter(keys: Set<AddressableNote>): List<TypedFilter>? {
val myAddressesToWatch = keys.filter { it.event == null }
if (myAddressesToWatch.isEmpty()) {
return null
}
return myAddressesToWatch.map {
it.address().let { aTag ->
if (aTag.kind < 25000 && aTag.dTag.isBlank()) {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(aTag.kind),
authors = listOf(aTag.pubKeyHex),
limit = 5,
),
)
} else {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
authors = listOf(aTag.pubKeyHex),
limit = 5,
),
)
}
}
}
}
private fun createRepliesAndReactionsFilter(keys: Set<Note>): List<TypedFilter>? {
if (keys.isEmpty()) {
return null
}
return groupByEOSEPresence(keys)
.map {
listOf(
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
ReactionEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
ReportEvent.KIND,
LnZapEvent.KIND,
PollNoteEvent.KIND,
OtsEvent.KIND,
TextNoteModificationEvent.KIND,
GitReplyEvent.KIND,
),
tags = mapOf("e" to it.map { it.idHex }),
since = findMinimumEOSEs(it),
// Max amount of "replies" to download on a specific event.
limit = 10000,
),
),
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
DeletionEvent.KIND,
NIP90ContentDiscoveryResponseEvent.KIND,
NIP90StatusEvent.KIND,
TorrentCommentEvent.KIND,
),
tags = mapOf("e" to it.map { it.idHex }),
since = findMinimumEOSEs(it),
limit = 100,
),
),
)
}.flatten()
}
private fun createQuotesFilter(keys: Set<Note>): List<TypedFilter>? =
groupByEOSEPresence(keys)
.map {
listOf(
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(TextNoteEvent.KIND),
tags = mapOf("q" to it.map { it.idHex }),
since = findMinimumEOSEs(it),
// Max amount of "replies" to download on a specific event.
limit = 1000,
),
),
)
}.flatten()
fun createLoadEventsIfNotLoadedFilter(keys: Set<Note>): List<TypedFilter>? {
val directEventsToLoad = keys.filter { it.event == null }
val threadingEventsToLoad =
keys
.mapNotNull { it.replyTo }
.flatten()
.filter { it !is AddressableNote && it.event == null }
val interestedEvents = (directEventsToLoad + threadingEventsToLoad).map { it.idHex }.toSet().toList()
if (interestedEvents.isEmpty()) {
return null
}
// downloads linked events to this event.
return listOf(
TypedFilter(
types = EVENT_FINDER_TYPES,
filter = SincePerRelayFilter(ids = interestedEvents),
),
) : ComposeSubscriptionManager<EventFinderQueryState>() {
val group =
listOf(
NoteEventLoaderSubAssembler(client, ::allKeys),
EventWatcherSubAssembler(client, ::allKeys),
)
}
val singleEventChannel =
requestNewSubscription { time, relayUrl ->
// Ignores EOSE if it is in the middle of a filter change.
if (isUpdatingFilters()) return@requestNewSubscription
override fun start() = group.forEach { it.start() }
checkNotInMainThread()
override fun stop() = group.forEach { it.stop() }
forEachSubscriber {
val eose = it.note.lastReactionsDownloadTime[relayUrl]
if (eose == null) {
it.note.lastReactionsDownloadTime += Pair(relayUrl, EOSETime(time))
} else {
eose.time = time
}
}
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
// Many relays operate with limits in the amount of filters.
// As information comes, the filters will be rotated to get more data.
invalidateFilters()
}
override fun invalidateKeys() = invalidateFilters()
override fun updateSubscriptions(keys: Set<EventFinderQueryState>) {
val addressables =
keys
.mapNotNullTo(mutableSetOf()) {
it.note as? AddressableNote
}.toSet()
override fun destroy() = group.forEach { it.destroy() }
val events =
keys
.mapNotNullTo(mutableSetOf()) {
if (it.note !is AddressableNote) {
it.note
} else {
null
}
}.toSet()
val eventReactions = createRepliesAndReactionsFilter(events)
val addressReactions = createReactionsToWatchInAddressFilter(addressables)
val missingEvents = createLoadEventsIfNotLoadedFilter(events)
val missingAddresses = createAddressFilter(addressables)
val quotes = createQuotesFilter(events + addressables)
singleEventChannel.typedFilters =
listOfNotNull(missingEvents, missingAddresses, eventReactions, addressReactions, quotes).flatten().ifEmpty { null }
}
}
fun groupByEOSEPresence(notes: Set<Note>): Collection<List<Note>> =
notes
.groupBy {
it.lastReactionsDownloadTime.keys
.sorted()
.joinToString(",")
}.values
.map {
it.sortedBy { it.idHex } // important to keep in order otherwise the Relay thinks the filter has changed and we REQ again
}
fun groupByEOSEPresence(users: Iterable<User>): Collection<List<User>> =
users
.groupBy {
it.latestEOSEs.keys
.sorted()
.joinToString(",")
}.values
.map {
it.sortedBy { it.pubkeyHex } // important to keep in order otherwise the Relay thinks the filter has changed and we REQ again
}
fun findMinimumEOSEs(notes: List<Note>): Map<String, EOSETime> {
val minLatestEOSEs = mutableMapOf<String, EOSETime>()
notes.forEach { note ->
note.lastReactionsDownloadTime.forEach {
val minEose = minLatestEOSEs[it.key]
if (minEose == null) {
minLatestEOSEs.put(it.key, EOSETime(it.value.time))
} else if (it.value.time < minEose.time) {
minEose.time = it.value.time
}
}
}
return minLatestEOSEs
}
fun findMinimumEOSEsForUsers(users: List<User>): Map<String, EOSETime> {
val minLatestEOSEs = mutableMapOf<String, EOSETime>()
users.forEach {
it.latestEOSEs.forEach {
val minEose = minLatestEOSEs[it.key]
if (minEose == null) {
minLatestEOSEs.put(it.key, EOSETime(it.value.time))
} else if (it.value.time < minEose.time) {
minEose.time = it.value.time
}
}
}
return minLatestEOSEs
override fun printStats() = group.forEach { it.printStats() }
}
@@ -0,0 +1,83 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.event.loaders
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
fun filterMissingAddressables(
keys: List<EventFinderQueryState>,
since: Map<String, EOSETime>?,
): List<TypedFilter>? {
val missingAddressables = mutableSetOf<Address>()
keys.forEach {
if (it.note is AddressableNote && it.note.event == null) {
missingAddressables.add(it.note.address())
}
// loads threading that is event-based
it.note.replyTo?.forEach {
if (it is AddressableNote && it.event == null) {
missingAddressables.add(it.address())
}
}
}
return filterMissingAddressables(missingAddressables, since)
}
fun filterMissingAddressables(
missingAddressables: Set<Address>,
since: Map<String, EOSETime>?,
): List<TypedFilter>? {
if (missingAddressables.isEmpty()) return null
return missingAddressables.map { aTag ->
if (aTag.kind < 25000 && aTag.dTag.isBlank()) {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(aTag.kind),
authors = listOf(aTag.pubKeyHex),
limit = 1,
),
)
} else {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
authors = listOf(aTag.pubKeyHex),
limit = 1,
),
)
}
}
}
@@ -0,0 +1,65 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.event.loaders
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip01Core.core.HexKey
fun filterMissingEvents(
keys: List<EventFinderQueryState>,
since: Map<String, EOSETime>?,
): List<TypedFilter>? {
val missingEvents = mutableSetOf<String>()
keys.forEach {
if (it.note !is AddressableNote && it.note.event == null) {
missingEvents.add(it.note.idHex)
}
// loads threading that is event-based
it.note.replyTo?.forEach {
if (it !is AddressableNote && it.event == null) {
missingEvents.add(it.idHex)
}
}
}
return filterMissingEvents(missingEvents, since)
}
fun filterMissingEvents(
missingEventIds: Set<HexKey>,
since: Map<String, EOSETime>?,
): List<TypedFilter>? {
if (missingEventIds.isEmpty()) return null
return listOf(
TypedFilter(
types = EVENT_FINDER_TYPES,
filter = SincePerRelayFilter(ids = missingEventIds.toList()),
),
)
}
@@ -0,0 +1,41 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.event.loaders
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.filters.EOSETime
class NoteEventLoaderSubAssembler(
client: NostrClient,
allKeys: () -> Set<EventFinderQueryState>,
) : SingleSubEoseManager<EventFinderQueryState>(client, allKeys, invalidateAfterEose = true) {
override fun updateFilter(
keys: List<EventFinderQueryState>,
since: Map<String, EOSETime>?,
) = listOfNotNull(
filterMissingEvents(keys, since),
filterMissingAddressables(keys, since),
).flatten()
override fun distinct(key: EventFinderQueryState) = key.note
}
@@ -0,0 +1,70 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.event.watchers
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
class EventWatcherSubAssembler(
client: NostrClient,
allKeys: () -> Set<EventFinderQueryState>,
) : SingleSubEoseManager<EventFinderQueryState>(client, allKeys) {
var lastNotesOnFilter = emptyList<Note>()
override fun newEose(
relayUrl: String,
time: Long,
) {
lastNotesOnFilter.forEach {
it.lastReactionsDownloadTime.newEose(relayUrl, time)
}
super.newEose(relayUrl, time)
}
override fun updateFilter(
keys: List<EventFinderQueryState>,
since: Map<String, EOSETime>?,
): List<TypedFilter>? {
if (keys.isEmpty()) {
return null
}
lastNotesOnFilter = keys.map { it.note }
val addressables = lastNotesOnFilter.filterIsInstance<AddressableNote>()
val events = keys.mapNotNull { if (it.note !is AddressableNote) it.note else null }
return groupByEOSEPresence(lastNotesOnFilter)
.map {
listOfNotNull(
filterRepliesAndReactionsToNotes(events),
filterRepliesAndReactionsToAddresses(addressables),
filterQuotesToNotes(it),
).flatten()
}.flatten()
}
override fun distinct(key: EventFinderQueryState) = key.note
}
@@ -0,0 +1,45 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.event.watchers
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
fun filterQuotesToNotes(keys: List<Note>): List<TypedFilter>? {
if (keys.isEmpty()) return null
return listOf(
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(TextNoteEvent.KIND),
tags = mapOf("q" to keys.map { it.idHex }),
since = findMinimumEOSEs(keys),
// Max amount of "replies" to download on a specific event.
limit = 1000,
),
),
)
}
@@ -0,0 +1,81 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.event.watchers
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
fun filterRepliesAndReactionsToAddresses(keys: List<AddressableNote>): List<TypedFilter>? {
if (keys.isEmpty()) return null
val addresses = keys.map { it.address().toValue() }
return listOf(
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
ReactionEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
ReportEvent.KIND,
LnZapEvent.KIND,
PollNoteEvent.KIND,
CommunityPostApprovalEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
),
tags = mapOf("a" to addresses),
since = findMinimumEOSEs(keys),
// Max amount of "replies" to download on a specific event.
limit = 1000,
),
),
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
DeletionEvent.KIND,
),
tags = mapOf("a" to addresses),
since = findMinimumEOSEs(keys),
// Max amount of "replies" to download on a specific event.
limit = 10,
),
),
)
}
@@ -0,0 +1,86 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.event.watchers
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent
fun filterRepliesAndReactionsToNotes(keys: List<Note>): List<TypedFilter>? {
if (keys.isEmpty()) return null
return listOf(
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
ReactionEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
ReportEvent.KIND,
LnZapEvent.KIND,
PollNoteEvent.KIND,
OtsEvent.KIND,
TextNoteModificationEvent.KIND,
GitReplyEvent.KIND,
),
tags = mapOf("e" to keys.map { it.idHex }),
since = findMinimumEOSEs(keys),
// Max amount of "replies" to download on a specific event.
limit = 1000,
),
),
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
DeletionEvent.KIND,
NIP90ContentDiscoveryResponseEvent.KIND,
NIP90StatusEvent.KIND,
TorrentCommentEvent.KIND,
),
tags = mapOf("e" to keys.map { it.idHex }),
since = findMinimumEOSEs(keys),
limit = 100,
),
),
)
}
@@ -0,0 +1,52 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.event.watchers
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.ammolite.relays.filters.EOSETime
fun groupByEOSEPresence(notes: Iterable<Note>): Collection<List<Note>> =
notes
.groupBy {
it.lastReactionsDownloadTime.relayList.keys
.sorted()
.joinToString(",")
}.values
.map {
it.sortedBy { it.idHex } // important to keep in order otherwise the Relay thinks the filter has changed and we REQ again
}
fun findMinimumEOSEs(notes: List<Note>): Map<String, EOSETime> {
val minLatestEOSEs = mutableMapOf<String, EOSETime>()
notes.forEach { note ->
note.lastReactionsDownloadTime.relayList.forEach {
val minEose = minLatestEOSEs[it.key]
if (minEose == null) {
minLatestEOSEs.put(it.key, EOSETime(it.value.time))
} else if (it.value.time < minEose.time) {
minEose.time = it.value.time
}
}
}
return minLatestEOSEs
}
@@ -0,0 +1,50 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.nwc
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
fun filterNWCPaymentsFromRequests(
serviceKeys: Set<HexKey>,
paymentRequets: Set<HexKey>,
fromUsers: Set<HexKey>,
since: Map<String, EOSETime>?,
): List<TypedFilter> =
listOf(
TypedFilter(
types = setOf(FeedType.WALLET_CONNECT),
filter =
SincePerRelayFilter(
kinds = listOf(LnZapPaymentResponseEvent.KIND),
authors = serviceKeys.toList(),
tags =
mapOf(
"e" to paymentRequets.toList(),
"p" to fromUsers.toList(),
),
),
),
)
@@ -20,12 +20,8 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.QueryBasedSubscriptionOrchestrator
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
// This allows multiple screen to be listening to tags, even the same tag
class NWCPaymentQueryState(
@@ -36,38 +32,21 @@ class NWCPaymentQueryState(
class NWCPaymentFilterAssembler(
client: NostrClient,
) : QueryBasedSubscriptionOrchestrator<NWCPaymentQueryState>(client) {
fun createAccountMetadataFilter(keys: Set<NWCPaymentQueryState>): TypedFilter? {
val fromAuthors = keys.mapTo(mutableSetOf()) { it.fromServiceHex }
val replyingToPayments = keys.mapTo(mutableSetOf()) { it.replyingToHex }
val aboutUsers = keys.mapTo(mutableSetOf()) { it.toUserHex }
if (fromAuthors.isEmpty()) return null
return TypedFilter(
types = setOf(FeedType.WALLET_CONNECT),
filter =
SincePerRelayFilter(
kinds = listOf(LnZapPaymentResponseEvent.KIND),
authors = fromAuthors.toList(),
tags =
mapOf(
"e" to replyingToPayments.toList(),
"p" to aboutUsers.toList(),
),
limit = 1,
),
) : ComposeSubscriptionManager<NWCPaymentQueryState>() {
val group =
listOf(
NWCPaymentWatcherSubAssembler(client, ::allKeys),
)
}
val channel = requestNewSubscription()
override fun start() = group.forEach { it.start() }
override fun updateSubscriptions(keys: Set<NWCPaymentQueryState>) {
val filter = createAccountMetadataFilter(keys)
if (filter != null) {
channel.typedFilters = listOf(filter)
} else {
channel.typedFilters = null
}
}
override fun stop() = group.forEach { it.stop() }
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun invalidateKeys() = invalidateFilters()
override fun destroy() = group.forEach { it.destroy() }
override fun printStats() = group.forEach { it.printStats() }
}
@@ -0,0 +1,46 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.nwc
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
class NWCPaymentWatcherSubAssembler(
client: NostrClient,
allKeys: () -> Set<NWCPaymentQueryState>,
) : SingleSubEoseManager<NWCPaymentQueryState>(client, allKeys) {
override fun updateFilter(
keys: List<NWCPaymentQueryState>,
since: Map<String, EOSETime>?,
): List<TypedFilter>? {
val fromAuthors = keys.mapTo(mutableSetOf()) { it.fromServiceHex }
val replyingToPayments = keys.mapTo(mutableSetOf()) { it.replyingToHex }
val aboutUsers = keys.mapTo(mutableSetOf()) { it.toUserHex }
if (fromAuthors.isEmpty()) return null
return filterNWCPaymentsFromRequests(fromAuthors, replyingToPayments, aboutUsers, since)
}
override fun distinct(key: NWCPaymentQueryState) = key.replyingToHex
}
@@ -21,20 +21,10 @@
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.QueryBasedSubscriptionOrchestrator
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.findMinimumEOSEsForUsers
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.groupByEOSEPresence
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders.UserLoaderSubAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserWatcherSubAssembler
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.experimental.relationshipStatus.RelationshipStatusEvent
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
// This allows multiple screen to be listening to tags, even the same tag
class UserFinderQueryState(
@@ -43,90 +33,22 @@ class UserFinderQueryState(
class UserFinderFilterAssembler(
client: NostrClient,
) : QueryBasedSubscriptionOrchestrator<UserFinderQueryState>(client) {
fun createUserMetadataFilter(keys: Set<UserFinderQueryState>): List<TypedFilter>? {
if (keys.isEmpty()) return null
val firstTimers = keys.filter { it.user.latestMetadata == null }.map { it.user.pubkeyHex }.distinct()
if (firstTimers.isEmpty()) return null
return listOf(
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND, AdvertisedRelayListEvent.KIND),
authors = firstTimers,
),
),
) : ComposeSubscriptionManager<UserFinderQueryState>() {
val group =
listOf(
UserLoaderSubAssembler(client, ::allKeys),
UserWatcherSubAssembler(client, ::allKeys),
)
}
fun createUserMetadataStatusReportFilter(keys: Set<UserFinderQueryState>): List<TypedFilter>? {
if (keys.isEmpty()) return null
override fun start() = group.forEach { it.start() }
val secondTimers = keys.filter { it.user.latestMetadata != null }.map { it.user }.toSet()
override fun stop() = group.forEach { it.stop() }
if (secondTimers.isEmpty()) return null
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
return groupByEOSEPresence(secondTimers)
.map { group ->
val groupIds = group.map { it.pubkeyHex }
val minEOSEs = findMinimumEOSEsForUsers(group)
override fun invalidateKeys() = invalidateFilters()
if (groupIds.isNotEmpty()) {
listOf(
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
MetadataEvent.KIND,
StatusEvent.KIND,
RelationshipStatusEvent.KIND,
AdvertisedRelayListEvent.KIND,
ChatMessageRelayListEvent.KIND,
),
authors = groupIds,
since = minEOSEs,
),
),
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(ReportEvent.KIND),
tags = mapOf("p" to groupIds),
since = minEOSEs,
),
),
)
} else {
listOf()
}
}.flatten()
}
override fun destroy() = group.forEach { it.destroy() }
val userChannel =
requestNewSubscription { time, relayUrl ->
forEachSubscriber {
val eose = it.user.latestEOSEs[relayUrl]
if (eose == null) {
it.user.latestEOSEs = it.user.latestEOSEs + Pair(relayUrl, EOSETime(time))
} else {
eose.time = time
}
}
}
override fun updateSubscriptions(keys: Set<UserFinderQueryState>) {
userChannel.typedFilters =
listOfNotNull(
createUserMetadataFilter(keys),
createUserMetadataStatusReportFilter(keys),
).flatten()
.ifEmpty { null }
}
override fun printStats() = group.forEach { it.printStats() }
}
@@ -0,0 +1,48 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.user.loaders
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
fun filterNewUserMetadataForKey(
authors: Set<HexKey>,
since: Map<String, EOSETime>?,
): List<TypedFilter> =
listOf(
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
MetadataEvent.KIND,
AdvertisedRelayListEvent.KIND,
),
authors = authors.toList(),
),
),
)
@@ -0,0 +1,45 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.user.loaders
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
class UserLoaderSubAssembler(
client: NostrClient,
allKeys: () -> Set<UserFinderQueryState>,
) : SingleSubEoseManager<UserFinderQueryState>(client, allKeys, invalidateAfterEose = true) {
override fun updateFilter(
keys: List<UserFinderQueryState>,
since: Map<String, EOSETime>?,
): List<TypedFilter>? {
val firstTimers = keys.filter { it.user.latestMetadata == null }.mapTo(mutableSetOf()) { it.user.pubkeyHex }
if (firstTimers.isEmpty()) return null
return filterNewUserMetadataForKey(firstTimers, since)
}
override fun distinct(key: UserFinderQueryState) = key.user
}
@@ -0,0 +1,44 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.user.watchers
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
fun filterReportsToKeys(
authors: Set<HexKey>,
since: Map<String, EOSETime>?,
): List<TypedFilter> =
listOf(
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(ReportEvent.KIND),
tags = mapOf("p" to authors.toList()),
since = since,
),
),
)
@@ -0,0 +1,55 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.user.watchers
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.experimental.relationshipStatus.RelationshipStatusEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
fun filterUserMetadataForKey(
authors: Set<HexKey>,
since: Map<String, EOSETime>?,
): List<TypedFilter> =
listOf(
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
MetadataEvent.KIND,
StatusEvent.KIND,
RelationshipStatusEvent.KIND,
AdvertisedRelayListEvent.KIND,
ChatMessageRelayListEvent.KIND,
),
authors = authors.toList(),
since = since,
),
),
)
@@ -0,0 +1,103 @@
/**
* Copyright (c) 2024 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.service.relayClient.reqCommand.user.watchers
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
class UserWatcherSubAssembler(
client: NostrClient,
allKeys: () -> Set<UserFinderQueryState>,
) : SingleSubEoseManager<UserFinderQueryState>(client, allKeys) {
var lastUsersOnFilter: Set<User> = emptySet()
var latestEOSEs: EOSEAccountFast = EOSEAccountFast(2000)
override fun newEose(
relayUrl: String,
time: Long,
) {
lastUsersOnFilter.forEach {
latestEOSEs.newEose(it, relayUrl, time)
}
super.newEose(relayUrl, time)
}
override fun updateFilter(
keys: List<UserFinderQueryState>,
since: Map<String, EOSETime>?,
): List<TypedFilter>? {
if (keys.isEmpty()) return null
lastUsersOnFilter = keys.filter { it.user.latestMetadata != null }.map { it.user }.toSet()
if (lastUsersOnFilter.isEmpty()) return null
return groupByRelayPresence(lastUsersOnFilter, latestEOSEs)
.map { group ->
val groupIds = group.map { it.pubkeyHex }.toSet()
if (groupIds.isNotEmpty()) {
val minEOSEs = findMinimumEOSEsForUsers(group, latestEOSEs)
filterUserMetadataForKey(groupIds, minEOSEs) + filterReportsToKeys(groupIds, minEOSEs)
} else {
emptyList()
}
}.flatten()
}
fun groupByRelayPresence(
users: Iterable<User>,
eoseCache: EOSEAccountFast,
): Collection<List<User>> =
users
.groupBy { eoseCache.since(it)?.keys?.hashCode() }
.values
.map {
// important to keep in order otherwise the Relay thinks the filter has changed and we REQ again
it.sortedBy { it.pubkeyHex }
}
fun findMinimumEOSEsForUsers(
users: List<User>,
eoseCache: EOSEAccountFast,
): Map<String, EOSETime> {
val minLatestEOSEs = mutableMapOf<String, EOSETime>()
users.forEach {
eoseCache.since(it)?.forEach {
val minEose = minLatestEOSEs[it.key]
if (minEose == null) {
minLatestEOSEs.put(it.key, EOSETime(it.value.time))
} else if (it.value.time < minEose.time) {
minEose.time = it.value.time
}
}
}
return minLatestEOSEs
}
override fun distinct(key: UserFinderQueryState) = key.user
}
@@ -21,50 +21,11 @@
package com.vitorpamplona.amethyst.service.relayClient.searchCommand
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.logTime
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.ammolite.relays.ALL_FEED_TYPES
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.MutableComposeSubscriptionManager
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.MutableQueryState
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies.SearchWatcherSubAssembler
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent
import com.vitorpamplona.quartz.experimental.nns.NNSEvent
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
import com.vitorpamplona.quartz.nip19Bech32.entities.Note
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.FollowListEvent
import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.utils.Hex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -80,172 +41,21 @@ class SearchFilterAssembler(
val cache: LocalCache,
client: NostrClient,
scope: CoroutineScope,
) : MutableQueryBasedSubscriptionOrchestrator<SearchQueryState>(client, scope) {
fun filterByAuthor(pubKey: HexKey) =
) : MutableComposeSubscriptionManager<SearchQueryState>(scope) {
val group =
listOf(
TypedFilter(
types = ALL_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND),
authors = listOfNotNull(pubKey),
limit = 1,
),
),
SearchWatcherSubAssembler(cache, client, ::allKeys),
)
fun filterByEvent(eventId: HexKey) =
listOf(
TypedFilter(
types = ALL_FEED_TYPES,
filter =
SincePerRelayFilter(
ids = listOfNotNull(eventId),
),
),
)
override fun start() = group.forEach { it.start() }
fun filterByAddress(parsed: NAddress) =
listOf(
TypedFilter(
types = ALL_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND),
authors = listOfNotNull(parsed.author),
limit = 1,
),
),
TypedFilter(
types = ALL_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(parsed.kind),
authors = listOfNotNull(parsed.author),
tags = mapOf("d" to listOf(parsed.dTag)),
limit = 5,
),
),
)
override fun stop() = group.forEach { it.stop() }
private fun createAnythingWithIDFilter(keys: Set<SearchQueryState>): List<TypedFilter> {
if (keys.isEmpty()) return emptyList()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
val uniqueQueries =
keys.mapNotNullTo(mutableSetOf()) {
if (!it.searchQuery.value.isBlank()) {
it.searchQuery.value
} else {
null
}
}
override fun invalidateKeys() = invalidateFilters()
return uniqueQueries
.map { mySearchString ->
val directFilters =
runCatching {
if (Hex.isHex(mySearchString)) {
val key = Hex.decode(mySearchString).toHexKey()
filterByAuthor(key) + filterByEvent(key)
} else {
when (val parsed = Nip19Parser.uriToRoute(mySearchString)?.entity) {
is NSec -> filterByAuthor(Nip01.pubKeyCreate(parsed.hex.hexToByteArray()).toHexKey())
is NPub -> filterByAuthor(parsed.hex)
is NProfile -> filterByAuthor(parsed.hex)
is Note -> filterByEvent(parsed.hex)
is NEvent -> filterByEvent(parsed.hex)
is NEmbed -> {
cache.justConsume(parsed.event, null)
emptyList()
}
override fun destroy() = group.forEach { it.destroy() }
is NRelay -> emptyList()
is NAddress -> filterByAddress(parsed)
else -> emptyList()
}
}
}.getOrDefault(emptyList())
val searchFilters =
listOfNotNull(
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND),
search = mySearchString,
limit = 1000,
),
),
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
LongTextNoteEvent.KIND,
BadgeDefinitionEvent.KIND,
PeopleListEvent.KIND,
BookmarkListEvent.KIND,
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
PinListEvent.KIND,
PollNoteEvent.KIND,
ChannelCreateEvent.KIND,
),
search = mySearchString,
limit = 100,
),
),
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
SincePerRelayFilter(
kinds =
listOf(
ChannelMetadataEvent.KIND,
ClassifiedsEvent.KIND,
CommunityDefinitionEvent.KIND,
EmojiPackEvent.KIND,
HighlightEvent.KIND,
LiveActivitiesEvent.KIND,
PollNoteEvent.KIND,
NNSEvent.KIND,
WikiNoteEvent.KIND,
CommentEvent.KIND,
),
search = mySearchString,
limit = 100,
),
),
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
SincePerRelayFilter(
kinds =
listOf(
InteractiveStoryPrologueEvent.KIND,
InteractiveStorySceneEvent.KIND,
FollowListEvent.KIND,
),
search = mySearchString,
limit = 100,
),
),
)
directFilters + searchFilters
}.flatten()
}
val searchChannel = requestNewSubscription()
override fun updateSubscriptions(keys: Set<SearchQueryState>) {
searchChannel.typedFilters =
logTime(
debugMessage = { "Search DataSource UpdateSubscriptions with ${it?.size} filter size" },
block = { createAnythingWithIDFilter(keys).ifEmpty { null } },
)
}
override fun printStats() = group.forEach { it.printStats() }
}
@@ -0,0 +1,63 @@
/**
* Copyright (c) 2024 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.service.relayClient.searchCommand.subassemblies
import com.fasterxml.jackson.databind.deser.std.FromStringDeserializer.types
import com.vitorpamplona.ammolite.relays.ALL_FEED_TYPES
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
fun filterByAddress(address: NAddress) =
listOf(
TypedFilter(
types = ALL_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND),
authors = listOfNotNull(address.author),
limit = 1,
),
),
if (address.kind < 25000 && address.dTag.isBlank()) {
TypedFilter(
types = ALL_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(address.kind),
authors = listOf(address.author),
limit = 5,
),
)
} else {
TypedFilter(
types = ALL_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(address.kind),
tags = mapOf("d" to listOf(address.dTag)),
authors = listOf(address.author),
limit = 5,
),
)
},
)
@@ -0,0 +1,40 @@
/**
* Copyright (c) 2024 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.service.relayClient.searchCommand.subassemblies
import com.vitorpamplona.ammolite.relays.ALL_FEED_TYPES
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
fun filterByAuthor(pubKey: HexKey) =
listOf(
TypedFilter(
types = ALL_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND),
authors = listOfNotNull(pubKey),
limit = 1,
),
),
)
@@ -0,0 +1,37 @@
/**
* Copyright (c) 2024 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.service.relayClient.searchCommand.subassemblies
import com.vitorpamplona.ammolite.relays.ALL_FEED_TYPES
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip01Core.core.HexKey
fun filterByEvent(eventId: HexKey) =
listOf(
TypedFilter(
types = ALL_FEED_TYPES,
filter =
SincePerRelayFilter(
ids = listOfNotNull(eventId),
),
),
)
@@ -0,0 +1,40 @@
/**
* Copyright (c) 2024 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.service.relayClient.searchCommand.subassemblies
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
fun searchPeopleByName(searchString: HexKey) =
listOf(
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND),
search = searchString,
limit = 1000,
),
),
)
@@ -0,0 +1,108 @@
/**
* Copyright (c) 2024 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.service.relayClient.searchCommand.subassemblies
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent
import com.vitorpamplona.quartz.experimental.nns.NNSEvent
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.FollowListEvent
import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
fun searchPostsByText(searchString: HexKey) =
listOf(
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
LongTextNoteEvent.KIND,
BadgeDefinitionEvent.KIND,
PeopleListEvent.KIND,
BookmarkListEvent.KIND,
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
PinListEvent.KIND,
PollNoteEvent.KIND,
ChannelCreateEvent.KIND,
),
search = searchString,
limit = 100,
),
),
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
SincePerRelayFilter(
kinds =
listOf(
ChannelMetadataEvent.KIND,
ClassifiedsEvent.KIND,
CommunityDefinitionEvent.KIND,
EmojiPackEvent.KIND,
HighlightEvent.KIND,
LiveActivitiesEvent.KIND,
PollNoteEvent.KIND,
NNSEvent.KIND,
WikiNoteEvent.KIND,
CommentEvent.KIND,
),
search = searchString,
limit = 100,
),
),
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
SincePerRelayFilter(
kinds =
listOf(
InteractiveStoryPrologueEvent.KIND,
InteractiveStorySceneEvent.KIND,
FollowListEvent.KIND,
),
search = searchString,
limit = 100,
),
),
)
@@ -0,0 +1,90 @@
/**
* Copyright (c) 2024 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.service.relayClient.searchCommand.subassemblies
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
import com.vitorpamplona.quartz.nip19Bech32.entities.Note
import com.vitorpamplona.quartz.utils.Hex
/**
* Creates a new sub for each Search Screen. The StateFlow of the text field
* that has the search ter is the id of the sub.
*/
class SearchWatcherSubAssembler(
val cache: LocalCache,
client: NostrClient,
allKeys: () -> Set<SearchQueryState>,
) : PerUniqueIdEoseManager<SearchQueryState>(client, allKeys) {
override fun updateFilter(
key: SearchQueryState,
since: Map<String, EOSETime>?,
): List<TypedFilter>? {
val mySearchString = key.searchQuery.value
if (mySearchString.isBlank()) return null
val directFilters =
runCatching {
if (Hex.isHex(mySearchString)) {
val key = Hex.decode(mySearchString).toHexKey()
filterByAuthor(key) + filterByEvent(key)
} else {
when (val parsed = Nip19Parser.uriToRoute(mySearchString)?.entity) {
is NSec -> filterByAuthor(Nip01.pubKeyCreate(parsed.hex.hexToByteArray()).toHexKey())
is NPub -> filterByAuthor(parsed.hex)
is NProfile -> filterByAuthor(parsed.hex)
is Note -> filterByEvent(parsed.hex)
is NEvent -> filterByEvent(parsed.hex)
is NEmbed -> {
cache.justConsume(parsed.event, null)
emptyList()
}
is NRelay -> emptyList()
is NAddress -> filterByAddress(parsed)
else -> emptyList()
}
}
}.getOrDefault(emptyList())
val searchFilters = searchPeopleByName(mySearchString) + searchPostsByText(mySearchString)
return directFilters + searchFilters
}
override fun id(key: SearchQueryState) = key.searchQuery.hashCode().toString()
}
@@ -21,12 +21,14 @@
package com.vitorpamplona.amethyst.service.relays
import androidx.collection.LruCache
import com.vitorpamplona.amethyst.model.LocalCache.users
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import kotlin.collections.plus
class EOSERelayList {
var relayList: Map<String, EOSETime> = emptyMap()
class EOSERelayList(
var relayList: Map<String, EOSETime> = emptyMap(),
) {
fun addOrUpdate(
relayUrl: String,
time: Long,
@@ -35,14 +37,29 @@ class EOSERelayList(
if (eose == null) {
relayList = relayList + Pair(relayUrl, EOSETime(time))
} else {
eose.time = time
if (time > eose.time) {
eose.update(time)
}
}
}
fun clear() {
relayList = emptyMap()
}
fun since() = relayList
fun newEose(
relayUrl: String,
time: Long,
) = addOrUpdate(relayUrl, time)
}
class EOSEFollowList(
var followList: Map<String, EOSERelayList> = emptyMap(),
cacheSize: Int = 200,
) {
var followList: LruCache<String, EOSERelayList> = LruCache<String, EOSERelayList>(cacheSize)
fun addOrUpdate(
listCode: String,
relayUrl: String,
@@ -52,16 +69,26 @@ class EOSEFollowList(
if (relayList == null) {
val newList = EOSERelayList()
newList.addOrUpdate(relayUrl, time)
followList = followList + mapOf(listCode to newList)
followList.put(listCode, newList)
} else {
relayList.addOrUpdate(relayUrl, time)
}
}
fun since(listCode: String) = followList[listCode]?.relayList
fun newEose(
listCode: String,
relayUrl: String,
time: Long,
) = addOrUpdate(listCode, relayUrl, time)
}
class EOSEAccount(
var users: LruCache<User, EOSEFollowList> = LruCache<User, EOSEFollowList>(20),
cacheSize: Int = 20,
) {
var users: LruCache<User, EOSEFollowList> = LruCache<User, EOSEFollowList>(cacheSize)
fun addOrUpdate(
user: User,
listCode: String,
@@ -81,4 +108,50 @@ class EOSEAccount(
fun removeDataFor(user: User) {
users.remove(user)
}
fun since(
key: User,
listCode: String,
) = users[key]?.followList?.get(listCode)?.relayList
fun newEose(
user: User,
listCode: String,
relayUrl: String,
time: Long,
) = addOrUpdate(user, listCode, relayUrl, time)
}
class EOSEAccountFast(
cacheSize: Int = 20,
) {
private val users: LruCache<User, EOSERelayList> = LruCache<User, EOSERelayList>(cacheSize)
fun addOrUpdate(
user: User,
relayUrl: String,
time: Long,
) {
val relayList = users[user]
if (relayList == null) {
val newList = EOSERelayList()
users.put(user, newList)
newList.addOrUpdate(relayUrl, time)
} else {
relayList.addOrUpdate(relayUrl, time)
}
}
fun removeDataFor(user: User) {
users.remove(user)
}
fun since(key: User) = users[key]?.relayList
fun newEose(
user: User,
relayUrl: String,
time: Long,
) = addOrUpdate(user, relayUrl, time)
}
@@ -21,104 +21,35 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.QueryBasedSubscriptionOrchestrator
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.datasources.Subscription
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
// This allows multiple screen to be listening to tags, even the same tag
class ChatroomQueryState(
val room: ChatroomKey,
val account: Account,
)
) {
val listId = room.hashCode().toString()
}
class ChatroomFilterAssembler(
client: NostrClient,
) : QueryBasedSubscriptionOrchestrator<ChatroomQueryState>(client) {
private val latestEOSEs = EOSEAccount()
fun createMessagesToMeFilter(key: ChatroomQueryState): TypedFilter? =
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter =
SincePerRelayFilter(
kinds = listOf(PrivateDmEvent.KIND),
authors = key.room.users.toList(),
tags = mapOf("p" to listOf(key.account.userProfile().pubkeyHex)),
since =
latestEOSEs.users[key.account.userProfile()]
?.followList
?.get(key.room.hashCode().toString())
?.relayList,
),
) : ComposeSubscriptionManager<ChatroomQueryState>() {
val group =
listOf(
ChatroomFilterSubAssembler(client, ::allKeys),
)
fun createMessagesFromMeFilter(key: ChatroomQueryState): TypedFilter? =
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter =
SincePerRelayFilter(
kinds = listOf(PrivateDmEvent.KIND),
authors = listOf(key.account.userProfile().pubkeyHex),
tags = mapOf("p" to key.room.users.map { it }),
since =
latestEOSEs.users[key.account.userProfile()]
?.followList
?.get(key.room.hashCode().toString())
?.relayList,
),
)
override fun start() = group.forEach { it.start() }
fun clearEOSEs(account: Account) {
latestEOSEs.removeDataFor(account.userProfile())
}
override fun stop() = group.forEach { it.stop() }
fun mergeAllFilters(key: ChatroomQueryState): List<TypedFilter>? =
listOfNotNull(
createMessagesToMeFilter(key),
createMessagesFromMeFilter(key),
).ifEmpty { null }
override fun invalidateKeys() = invalidateFilters()
fun newSub(key: ChatroomQueryState): Subscription =
requestNewSubscription { time, relayUrl ->
latestEOSEs.addOrUpdate(key.account.userProfile(), key.room.hashCode().toString(), relayUrl, time)
}
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
val userSubscriptionMap = mutableMapOf<User, String>()
override fun destroy() = group.forEach { it.start() }
fun findOrCreateSubFor(key: ChatroomQueryState): Subscription {
var subId = userSubscriptionMap[key.account.userProfile()]
return if (subId == null) {
newSub(key).also { userSubscriptionMap[key.account.userProfile()] = it.id }
} else {
getSub(subId) ?: newSub(key).also { userSubscriptionMap[key.account.userProfile()] = it.id }
}
}
// One sub per subscribed account
override fun updateSubscriptions(keys: Set<ChatroomQueryState>) {
val uniqueSubscribedAccounts = keys.distinctBy { it.account }
val updated = mutableSetOf<User>()
uniqueSubscribedAccounts.forEach {
val user = it.account.userProfile()
val sub = findOrCreateSubFor(it)
sub.typedFilters = mergeAllFilters(it)
updated.add(user)
}
userSubscriptionMap.forEach {
if (it.key !in updated) {
dismissSubscription(it.value)
}
}
}
override fun printStats() = group.forEach { it.printStats() }
}
@@ -0,0 +1,45 @@
/**
* Copyright (c) 2024 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.ui.screen.loggedIn.chats.privateDM.datasource
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
class ChatroomFilterSubAssembler(
client: NostrClient,
allKeys: () -> Set<ChatroomQueryState>,
) : PerUserAndFollowListEoseManager<ChatroomQueryState>(client, allKeys) {
override fun updateFilter(
key: ChatroomQueryState,
since: Map<String, EOSETime>?,
): List<TypedFilter>? =
filterNip04DMs(
key.room.users,
key.account.userProfile().pubkeyHex,
since,
)
override fun user(key: ChatroomQueryState) = key.account.userProfile()
override fun list(key: ChatroomQueryState) = key.listId
}
@@ -0,0 +1,60 @@
/**
* Copyright (c) 2024 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.ui.screen.loggedIn.chats.privateDM.datasource
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import kotlin.collections.toList
fun filterNip04DMs(
group: Set<HexKey>?,
user: HexKey?,
since: Map<String, EOSETime>?,
): List<TypedFilter>? {
if (group == null || group.isEmpty() || user == null || user.isEmpty()) return null
return listOf(
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter =
SincePerRelayFilter(
kinds = listOf(PrivateDmEvent.KIND),
authors = group.toList(),
tags = mapOf("p" to listOf(user)),
since = since,
),
),
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter =
SincePerRelayFilter(
kinds = listOf(PrivateDmEvent.KIND),
authors = listOf(user),
tags = mapOf("p" to group.toList()),
since = since,
),
),
)
}
@@ -22,21 +22,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datas
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.QueryBasedSubscriptionOrchestrator
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.EOSEFollowList
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies.ChannelFromUserFilterSubAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies.ChannelPublicFilterSubAssembler
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.datasources.Subscription
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
// This allows multiple screen to be listening to tags, even the same tag
class ChannelQueryState(
@@ -46,169 +35,24 @@ class ChannelQueryState(
class ChannelFilterAssembler(
client: NostrClient,
) : QueryBasedSubscriptionOrchestrator<ChannelQueryState>(client) {
companion object {
val RELAY_SET =
setOf(
FeedType.FOLLOWS,
FeedType.PRIVATE_DMS,
FeedType.GLOBAL,
FeedType.SEARCH,
)
}
private val latestEOSEPerAccount = EOSEAccount()
private val latestEOSEPerChannel = EOSEFollowList()
fun sinceUser(key: ChannelQueryState) =
latestEOSEPerAccount.users[key.account.userProfile()]
?.followList
?.get(key.channel.idHex)
?.relayList
fun since(key: ChannelQueryState) = latestEOSEPerChannel.followList.get(key.channel.idHex)?.relayList
fun newEose(
key: ChannelQueryState,
relayUrl: String,
time: Long,
) {
latestEOSEPerAccount.addOrUpdate(
key.account.userProfile(),
key.channel.idHex,
relayUrl,
time,
) : ComposeSubscriptionManager<ChannelQueryState>() {
val group =
listOf(
// Requests the latest messages
ChannelPublicFilterSubAssembler(client, ::allKeys),
// Requests the latest messages by logged in user
ChannelFromUserFilterSubAssembler(client, ::allKeys),
)
latestEOSEPerChannel.addOrUpdate(key.channel.idHex, relayUrl, time)
}
override fun start() = group.forEach { it.start() }
fun createMessagesByMeToChannelFilter(key: ChannelQueryState): TypedFilter? =
when (key.channel) {
is PublicChatChannel ->
TypedFilter(
types = RELAY_SET,
filter =
SincePerRelayFilter(
kinds = listOf(ChannelMessageEvent.KIND),
authors = listOf(key.account.userProfile().pubkeyHex),
limit = 50,
since = sinceUser(key),
),
)
is LiveActivitiesChannel ->
TypedFilter(
types = RELAY_SET,
filter =
SincePerRelayFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND),
authors = listOf(key.account.userProfile().pubkeyHex),
limit = 50,
since = sinceUser(key),
),
)
is EphemeralChatChannel ->
null // there is nothing in the past
else -> {
null
}
}
override fun stop() = group.forEach { it.stop() }
fun createMessagesToChannelFilter(key: ChannelQueryState): TypedFilter? {
val channel = key.channel
override fun invalidateKeys() = invalidateFilters()
return when (channel) {
is PublicChatChannel ->
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
SincePerRelayFilter(
kinds = listOf(ChannelMessageEvent.KIND),
tags = mapOf("e" to listOfNotNull(channel.idHex)),
limit = 200,
since = since(key),
),
)
is LiveActivitiesChannel ->
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
SincePerRelayFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND),
tags = mapOf("a" to listOfNotNull(channel.idHex)),
limit = 200,
since = since(key),
),
)
is EphemeralChatChannel -> {
TypedFilter(
types =
setOf(
FeedType.FOLLOWS,
FeedType.PUBLIC_CHATS,
FeedType.GLOBAL,
),
filter =
SincePerRelayFilter(
kinds = listOf(EphemeralChatEvent.KIND),
tags =
if (channel.roomId.id.isBlank()) {
mapOf("d" to listOf("_"))
} else {
mapOf("d" to listOfNotNull(channel.roomId.id))
},
limit = 200,
since = since(key),
),
)
}
else -> {
null
}
}
return null
}
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
fun mergeAllFilters(key: ChannelQueryState): List<TypedFilter>? =
listOfNotNull(
createMessagesToChannelFilter(key),
createMessagesByMeToChannelFilter(key),
).ifEmpty { null }
override fun destroy() = group.forEach { it.start() }
fun newSub(key: ChannelQueryState): Subscription =
requestNewSubscription { time, relayUrl ->
newEose(key, relayUrl, time)
}
val userSubscriptionMap = mutableMapOf<User, String>()
fun findOrCreateSubFor(key: ChannelQueryState): Subscription {
var subId = userSubscriptionMap[key.account.userProfile()]
return if (subId == null) {
newSub(key).also { userSubscriptionMap[key.account.userProfile()] = it.id }
} else {
getSub(subId) ?: newSub(key).also { userSubscriptionMap[key.account.userProfile()] = it.id }
}
}
// One sub per subscribed account
override fun updateSubscriptions(keys: Set<ChannelQueryState>) {
val uniqueSubscribedAccounts = keys.distinctBy { it.account }
val updated = mutableSetOf<User>()
uniqueSubscribedAccounts.forEach {
val user = it.account.userProfile()
val sub = findOrCreateSubFor(it)
sub.typedFilters = mergeAllFilters(it)
updated.add(user)
}
userSubscriptionMap.forEach {
if (it.key !in updated) {
dismissSubscription(it.value)
}
}
}
override fun printStats() = group.forEach { it.printStats() }
}
@@ -0,0 +1,52 @@
/**
* Copyright (c) 2024 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.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies
import com.vitorpamplona.amethyst.model.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelQueryState
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
class ChannelFromUserFilterSubAssembler(
client: NostrClient,
allKeys: () -> Set<ChannelQueryState>,
) : PerUserAndFollowListEoseManager<ChannelQueryState>(client, allKeys) {
override fun updateFilter(
key: ChannelQueryState,
since: Map<String, EOSETime>?,
): List<TypedFilter>? =
when (val channel = key.channel) {
is EphemeralChatChannel -> filterMyMessagesToEphemeralChat(channel, userHex(key), since)
is PublicChatChannel -> filterMyMessagesToPublicChat(channel, user(key).pubkeyHex, since)
is LiveActivitiesChannel -> filterMyMessagesToLiveActivities(channel, userHex(key), since)
else -> null
}
fun userHex(key: ChannelQueryState) = key.account.userProfile().pubkeyHex
override fun user(key: ChannelQueryState) = key.account.userProfile()
override fun list(key: ChannelQueryState) = key.channel.idHex
}
@@ -0,0 +1,48 @@
/**
* Copyright (c) 2024 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.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies
import com.vitorpamplona.amethyst.model.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelQueryState
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
class ChannelPublicFilterSubAssembler(
client: NostrClient,
allKeys: () -> Set<ChannelQueryState>,
) : PerUniqueIdEoseManager<ChannelQueryState>(client, allKeys) {
override fun updateFilter(
key: ChannelQueryState,
since: Map<String, EOSETime>?,
): List<TypedFilter>? =
when (val channel = key.channel) {
is EphemeralChatChannel -> filterMessagesToEphemeralChat(channel, since)
is PublicChatChannel -> filterMessagesToPublicChat(channel, since)
is LiveActivitiesChannel -> filterMessagesToLiveActivities(channel, since)
else -> null
}
override fun id(key: ChannelQueryState) = key.channel.idHex
}
@@ -0,0 +1,51 @@
/**
* Copyright (c) 2024 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.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies
import com.vitorpamplona.amethyst.model.EphemeralChatChannel
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
fun filterMessagesToEphemeralChat(
channel: EphemeralChatChannel,
since: Map<String, EOSETime>?,
): List<TypedFilter>? =
listOf(
TypedFilter(
types =
setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL),
filter =
SincePerRelayFilter(
kinds = listOf(EphemeralChatEvent.KIND),
tags =
if (channel.roomId.id.isBlank()) {
mapOf("d" to listOf("_"))
} else {
mapOf("d" to listOfNotNull(channel.roomId.id))
},
limit = 200,
since = since,
),
),
)
@@ -0,0 +1,45 @@
/**
* Copyright (c) 2024 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.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
fun filterMessagesToLiveActivities(
channel: LiveActivitiesChannel,
since: Map<String, EOSETime>?,
): List<TypedFilter>? =
listOf(
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
SincePerRelayFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND),
tags = mapOf("a" to listOfNotNull(channel.idHex)),
limit = 200,
since = since,
),
),
)
@@ -0,0 +1,45 @@
/**
* Copyright (c) 2024 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.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
fun filterMessagesToPublicChat(
channel: PublicChatChannel,
since: Map<String, EOSETime>?,
): List<TypedFilter>? =
listOf(
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
SincePerRelayFilter(
kinds = listOf(ChannelMessageEvent.KIND),
tags = mapOf("e" to listOfNotNull(channel.idHex)),
limit = 200,
since = since,
),
),
)
@@ -0,0 +1,54 @@
/**
* Copyright (c) 2024 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.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies
import com.vitorpamplona.amethyst.model.EphemeralChatChannel
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
fun filterMyMessagesToEphemeralChat(
channel: EphemeralChatChannel,
pubKey: HexKey,
since: Map<String, EOSETime>?,
): List<TypedFilter>? =
listOf(
TypedFilter(
types =
setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL),
filter =
SincePerRelayFilter(
kinds = listOf(EphemeralChatEvent.KIND),
tags =
if (channel.roomId.id.isBlank()) {
mapOf("d" to listOf("_"))
} else {
mapOf("d" to listOfNotNull(channel.roomId.id))
},
authors = listOf(pubKey),
limit = 50,
since = since,
),
),
)
@@ -0,0 +1,48 @@
/**
* Copyright (c) 2024 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.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
fun filterMyMessagesToLiveActivities(
channel: LiveActivitiesChannel,
pubKey: HexKey,
since: Map<String, EOSETime>?,
): List<TypedFilter>? =
listOf(
TypedFilter(
types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH),
filter =
SincePerRelayFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND),
tags = mapOf("a" to listOfNotNull(channel.idHex)),
authors = listOf(pubKey),
limit = 50,
since = since,
),
),
)
@@ -0,0 +1,48 @@
/**
* Copyright (c) 2024 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.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
fun filterMyMessagesToPublicChat(
channel: PublicChatChannel,
pubKey: HexKey,
since: Map<String, EOSETime>?,
): List<TypedFilter>? =
listOf(
TypedFilter(
types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH),
filter =
SincePerRelayFilter(
kinds = listOf(ChannelMessageEvent.KIND),
tags = mapOf("e" to listOfNotNull(channel.idHex)),
authors = listOf(pubKey),
limit = 50,
since = since,
),
),
)
@@ -21,22 +21,8 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.QueryBasedSubscriptionOrchestrator
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.datasources.Subscription
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import kotlinx.coroutines.flow.toList
import kotlin.collections.flatten
// This allows multiple screen to be listening to tags, even the same tag
class ChatroomListState(
@@ -45,194 +31,23 @@ class ChatroomListState(
class ChatroomListFilterAssembler(
client: NostrClient,
) : QueryBasedSubscriptionOrchestrator<ChatroomListState>(client) {
val latestEOSEs = EOSEAccount()
val chatRoomListKey = "ChatroomList"
fun since(key: ChatroomListState) =
latestEOSEs.users[key.account.userProfile()]
?.followList
?.get(chatRoomListKey)
?.relayList
fun newEose(
key: ChatroomListState,
relayUrl: String,
time: Long,
) {
latestEOSEs.addOrUpdate(
key.account.userProfile(),
chatRoomListKey,
relayUrl,
time,
)
}
fun createMessagesToMeFilter(key: ChatroomListState) =
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter =
SincePerRelayFilter(
kinds = listOf(PrivateDmEvent.KIND),
tags = mapOf("p" to listOf(key.account.userProfile().pubkeyHex)),
since = since(key),
),
) : ComposeSubscriptionManager<ChatroomListState>() {
val group =
listOf(
DMsFromUserFilterSubAssembler(client, ::allKeys),
FollowingPublicChatSubAssembler(client, ::allKeys),
FollowingEphemeralChatSubAssembler(client, ::allKeys),
)
fun createMessagesFromMeFilter(key: ChatroomListState) =
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter =
SincePerRelayFilter(
kinds = listOf(PrivateDmEvent.KIND),
authors = listOf(key.account.userProfile().pubkeyHex),
since = since(key),
),
)
override fun start() = group.forEach { it.start() }
fun createChannelsCreatedbyMeFilter(key: ChatroomListState) =
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
SincePerRelayFilter(
kinds =
listOf(
ChannelCreateEvent.KIND,
ChannelMetadataEvent.KIND,
),
authors = listOf(key.account.userProfile().pubkeyHex),
since = since(key),
),
)
override fun stop() = group.forEach { it.stop() }
fun createMyChannelsFilter(key: ChatroomListState): TypedFilter? {
val followingEvents = key.account.publicChatList.livePublicChatEventIdSet.value
override fun invalidateKeys() = invalidateFilters()
if (followingEvents.isEmpty()) return null
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
return TypedFilter(
// Metadata comes from any relay
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(ChannelCreateEvent.KIND),
ids = followingEvents.toList(),
since = since(key),
),
)
}
override fun destroy() = group.forEach { it.start() }
fun createLastChannelInfoFilter(key: ChatroomListState): List<TypedFilter>? {
val followingEvents =
key.account.publicChatList.livePublicChatEventIdSet.value
if (followingEvents.isEmpty()) return null
return followingEvents.map {
TypedFilter(
// Metadata comes from any relay
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(ChannelMetadataEvent.KIND),
tags = mapOf("e" to listOf(it)),
since = since(key),
limit = 1,
),
)
}
}
fun createLastMessageOfEachChannelFilter(key: ChatroomListState): List<TypedFilter>? {
val followingEvents =
key.account.publicChatList.livePublicChatEventIdSet.value
if (followingEvents.isEmpty()) return null
return followingEvents.map {
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
SincePerRelayFilter(
kinds = listOf(ChannelMessageEvent.KIND),
tags = mapOf("e" to listOf(it)),
since = since(key),
// Remember to consider spam that is being removed from the UI
limit = 50,
),
)
}
}
fun createLastMessageOfEachEphemeralChatFilter(key: ChatroomListState): List<TypedFilter>? {
val followingEvents =
key.account.ephemeralChatList.liveEphemeralChatList.value
.map { it.id }
if (followingEvents.isEmpty()) return null
return listOf(
TypedFilter(
// Metadata comes from any relay
types = setOf(FeedType.PUBLIC_CHATS),
filter =
SincePerRelayFilter(
kinds = listOf(EphemeralChatEvent.KIND),
tags = mapOf("d" to followingEvents),
since = since(key),
// Remember to consider spam that is being removed from the UI
limit = 50,
),
),
)
}
fun mergeAllFilters(key: ChatroomListState): List<TypedFilter>? =
listOfNotNull(
listOfNotNull(
createMessagesToMeFilter(key),
createMessagesFromMeFilter(key),
createMyChannelsFilter(key),
),
createLastChannelInfoFilter(key),
createLastMessageOfEachChannelFilter(key),
createLastMessageOfEachEphemeralChatFilter(key),
).flatten().ifEmpty { null }
fun newSub(key: ChatroomListState): Subscription =
requestNewSubscription { time, relayUrl ->
newEose(key, relayUrl, time)
}
val userSubscriptionMap = mutableMapOf<User, String>()
fun findOrCreateSubFor(key: ChatroomListState): Subscription {
var subId = userSubscriptionMap[key.account.userProfile()]
return if (subId == null) {
newSub(key).also { userSubscriptionMap[key.account.userProfile()] = it.id }
} else {
getSub(subId) ?: newSub(key).also { userSubscriptionMap[key.account.userProfile()] = it.id }
}
}
// One sub per subscribed account
override fun updateSubscriptions(keys: Set<ChatroomListState>) {
val uniqueSubscribedAccounts = keys.distinctBy { it.account }
val updated = mutableSetOf<User>()
uniqueSubscribedAccounts.forEach {
val user = it.account.userProfile()
val sub = findOrCreateSubFor(it)
sub.typedFilters = mergeAllFilters(it)
updated.add(user)
}
userSubscriptionMap.forEach {
if (it.key !in updated) {
dismissSubscription(it.value)
}
}
}
override fun printStats() = group.forEach { it.printStats() }
}
@@ -0,0 +1,41 @@
/**
* Copyright (c) 2024 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.ui.screen.loggedIn.chats.rooms.datasource
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
class DMsFromUserFilterSubAssembler(
client: NostrClient,
allKeys: () -> Set<ChatroomListState>,
) : PerUserEoseManager<ChatroomListState>(client, allKeys) {
override fun updateFilter(
key: ChatroomListState,
since: Map<String, EOSETime>?,
): List<TypedFilter>? =
listOfNotNull(
filterNip04DMsToAndFromMe(key.account.userProfile().pubkeyHex, since),
).flatten()
override fun user(key: ChatroomListState) = key.account.userProfile()
}
@@ -0,0 +1,58 @@
/**
* Copyright (c) 2024 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.ui.screen.loggedIn.chats.rooms.datasource
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
fun filterFollowingEphemeralChats(
followingChannels: Set<RoomId>?,
since: Map<String, EOSETime>?,
): List<TypedFilter>? {
if (followingChannels == null || followingChannels.isEmpty()) return null
val dTags =
followingChannels.map {
if (it.id.isBlank()) {
"_"
} else {
it.id
}
}
return listOf(
TypedFilter(
// Metadata comes from any relay
types = setOf(FeedType.PUBLIC_CHATS),
filter =
SincePerRelayFilter(
kinds = listOf(EphemeralChatEvent.KIND),
tags = mapOf("d" to dTags),
limit = 100,
since = since,
),
),
)
}
@@ -0,0 +1,48 @@
/**
* Copyright (c) 2024 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.ui.screen.loggedIn.chats.rooms.datasource
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import kotlin.collections.toList
fun filterFollowingPublicChats(
followingChannels: Set<HexKey>?,
since: Map<String, EOSETime>?,
): List<TypedFilter>? {
if (followingChannels == null || followingChannels.isEmpty()) return null
return listOf(
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(ChannelCreateEvent.KIND),
ids = followingChannels.toList(),
since = since,
),
),
)
}
@@ -0,0 +1,63 @@
/**
* Copyright (c) 2024 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.ui.screen.loggedIn.chats.rooms.datasource
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import kotlin.collections.toList
fun filterLastMessageFollowingPublicChats(
followingChannels: Set<HexKey>?,
since: Map<String, EOSETime>?,
): List<TypedFilter>? {
if (followingChannels == null || followingChannels.isEmpty()) return null
return listOf(
TypedFilter(
// Metadata comes from any relay
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(ChannelMetadataEvent.KIND),
tags = mapOf("e" to followingChannels.toList()),
since = since,
limit = 1,
),
),
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
SincePerRelayFilter(
kinds = listOf(ChannelMessageEvent.KIND),
tags = mapOf("e" to followingChannels.toList()),
since = since,
// Remember to consider spam that is being removed from the UI
limit = 100,
),
),
)
}
@@ -0,0 +1,56 @@
/**
* Copyright (c) 2024 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.ui.screen.loggedIn.chats.rooms.datasource
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
fun filterNip04DMsToAndFromMe(
user: HexKey?,
since: Map<String, EOSETime>?,
): List<TypedFilter>? {
if (user == null || user.isEmpty()) return null
return listOf(
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter =
SincePerRelayFilter(
kinds = listOf(PrivateDmEvent.KIND),
tags = mapOf("p" to listOf(user)),
since = since,
),
),
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter =
SincePerRelayFilter(
kinds = listOf(PrivateDmEvent.KIND),
authors = listOf(user),
since = since,
),
),
)
}
@@ -0,0 +1,75 @@
/**
* Copyright (c) 2024 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.ui.screen.loggedIn.chats.rooms.datasource
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.datasources.Subscription
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
import kotlin.collections.forEach
class FollowingEphemeralChatSubAssembler(
client: NostrClient,
allKeys: () -> Set<ChatroomListState>,
) : PerUserEoseManager<ChatroomListState>(client, allKeys) {
override fun updateFilter(
key: ChatroomListState,
since: Map<String, EOSETime>?,
): List<TypedFilter>? =
listOfNotNull(
filterFollowingEphemeralChats(key.account.ephemeralChatList.liveEphemeralChatList.value, since),
).flatten()
override fun user(key: ChatroomListState) = key.account.userProfile()
val userJobMap = mutableMapOf<User, List<Job>>()
@OptIn(FlowPreview::class)
override fun newSub(key: ChatroomListState): Subscription {
userJobMap[key.account.userProfile()]?.forEach { it.cancel() }
userJobMap[key.account.userProfile()] =
listOf(
key.account.scope.launch(Dispatchers.Default) {
key.account.ephemeralChatList.liveEphemeralChatList.sample(5000).collectLatest {
invalidateFilters()
}
},
)
return super.newSub(key)
}
override fun endSub(
key: User,
subId: String,
) {
return super.endSub(key, subId)
userJobMap[key]?.forEach { it.cancel() }
}
}
@@ -0,0 +1,76 @@
/**
* Copyright (c) 2024 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.ui.screen.loggedIn.chats.rooms.datasource
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.datasources.Subscription
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
import kotlin.collections.forEach
class FollowingPublicChatSubAssembler(
client: NostrClient,
allKeys: () -> Set<ChatroomListState>,
) : PerUserEoseManager<ChatroomListState>(client, allKeys) {
override fun updateFilter(
key: ChatroomListState,
since: Map<String, EOSETime>?,
): List<TypedFilter>? =
listOfNotNull(
filterLastMessageFollowingPublicChats(key.account.publicChatList.livePublicChatEventIdSet.value, since),
filterFollowingPublicChats(key.account.publicChatList.livePublicChatEventIdSet.value, since),
).flatten()
override fun user(key: ChatroomListState) = key.account.userProfile()
val userJobMap = mutableMapOf<User, List<Job>>()
@OptIn(FlowPreview::class)
override fun newSub(key: ChatroomListState): Subscription {
userJobMap[key.account.userProfile()]?.forEach { it.cancel() }
userJobMap[key.account.userProfile()] =
listOf(
key.account.scope.launch(Dispatchers.Default) {
key.account.publicChatList.livePublicChatEventIdSet.sample(5000).collectLatest {
invalidateFilters()
}
},
)
return super.newSub(key)
}
override fun endSub(
key: User,
subId: String,
) {
return super.endSub(key, subId)
userJobMap[key]?.forEach { it.cancel() }
}
}
@@ -0,0 +1,50 @@
/**
* Copyright (c) 2024 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.ui.screen.loggedIn.communities.datasource
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
class CommunityFeedFilterSubAssembler(
client: NostrClient,
allKeys: () -> Set<CommunityQueryState>,
) : SingleSubEoseManager<CommunityQueryState>(client, allKeys) {
override fun updateFilter(
keys: List<CommunityQueryState>,
since: Map<String, EOSETime>?,
): List<TypedFilter>? {
if (keys.isEmpty()) return emptyList()
return keys.mapNotNull {
val commEvent = it.community.event
if (commEvent is CommunityDefinitionEvent) {
filterCommunityPosts(commEvent, since)
} else {
null
}
}
}
override fun distinct(key: CommunityQueryState) = key.community.idHex
}
@@ -21,13 +21,8 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.QueryBasedSubscriptionOrchestrator
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
// This allows multiple screen to be listening to tags, even the same tag
class CommunityQueryState(
@@ -36,37 +31,21 @@ class CommunityQueryState(
class CommunityFilterAssembler(
client: NostrClient,
) : QueryBasedSubscriptionOrchestrator<CommunityQueryState>(client) {
private fun createLoadCommunityFilter(keys: Set<CommunityQueryState>): List<TypedFilter> {
if (keys.isEmpty()) return emptyList()
) : ComposeSubscriptionManager<CommunityQueryState>() {
val group =
listOf(
CommunityFeedFilterSubAssembler(client, ::allKeys),
)
val uniqueCommunities =
keys.associate {
it.community.address.toValue() to it.community
}
override fun start() = group.forEach { it.start() }
return uniqueCommunities.mapNotNull {
val commEvent = it.value.event
if (commEvent is CommunityDefinitionEvent) {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
authors = commEvent.moderators().map { it.pubKey }.plus(listOfNotNull(it.value.author?.pubkeyHex)),
tags = mapOf("a" to listOf(it.value.address.toValue())),
kinds = listOf(CommunityPostApprovalEvent.KIND),
limit = 500,
),
)
} else {
null
}
}
}
override fun stop() = group.forEach { it.stop() }
val loadCommunityChannel = requestNewSubscription()
override fun invalidateKeys() = invalidateFilters()
override fun updateSubscriptions(keys: Set<CommunityQueryState>) {
loadCommunityChannel.typedFilters = createLoadCommunityFilter(keys).ifEmpty { null }
}
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.start() }
override fun printStats() = group.forEach { it.printStats() }
}
@@ -0,0 +1,44 @@
/**
* Copyright (c) 2024 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.ui.screen.loggedIn.communities.datasource
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
fun filterCommunityPosts(
community: CommunityDefinitionEvent,
since: Map<String, EOSETime>?,
): TypedFilter =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
authors = community.moderators().map { it.pubKey }.plus(community.pubKey),
tags = mapOf("a" to listOf(community.addressTag())),
kinds = listOf(CommunityPostApprovalEvent.KIND),
limit = 500,
since = since,
),
)
@@ -21,31 +21,9 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.QueryBasedSubscriptionOrchestrator
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.datasources.Subscription
import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip51Lists.FollowListEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.util.Locale
// This allows multiple screen to be listening to tags, even the same tag
class DiscoveryQueryState(
@@ -55,508 +33,22 @@ class DiscoveryQueryState(
class DiscoveryFilterAssembler(
client: NostrClient,
) : QueryBasedSubscriptionOrchestrator<DiscoveryQueryState>(client) {
val latestEOSEs = EOSEAccount()
fun since(key: DiscoveryQueryState) =
latestEOSEs.users[key.account.userProfile()]
?.followList
?.get(key.account.settings.defaultDiscoveryFollowList.value)
?.relayList
fun newEose(
key: DiscoveryQueryState,
relayUrl: String,
time: Long,
) {
latestEOSEs.addOrUpdate(
key.account.userProfile(),
key.account.settings.defaultDiscoveryFollowList.value,
relayUrl,
time,
)
}
fun createMarketplaceFilter(key: DiscoveryQueryState): List<TypedFilter> {
val follows =
key.account.liveDiscoveryListAuthorsPerRelay.value
?.ifEmpty { null }
val hashToLoad =
key.account.liveDiscoveryFollowLists.value
?.hashtags
?.toList()
?.ifEmpty { null }
val geohashToLoad =
key.account.liveDiscoveryFollowLists.value
?.geotags
?.toList()
?.ifEmpty { null }
return listOfNotNull(
TypedFilter(
types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS),
filter =
SinceAuthorPerRelayFilter(
authors = follows,
kinds = listOf(ClassifiedsEvent.KIND),
limit = 300,
since = since(key),
),
),
hashToLoad?.let {
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
SincePerRelayFilter(
kinds = listOf(ClassifiedsEvent.KIND),
tags =
mapOf(
"t" to
it
.map {
listOf(
it,
it.lowercase(),
it.uppercase(),
it.replaceFirstChar {
if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString()
},
)
}.flatten(),
),
limit = 300,
since = since(key),
),
)
},
geohashToLoad?.let {
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
SincePerRelayFilter(
kinds = listOf(ClassifiedsEvent.KIND),
tags =
mapOf(
"g" to it,
),
limit = 300,
since = since(key),
),
)
},
)
}
fun createNIP89Filter(key: DiscoveryQueryState): List<TypedFilter> =
listOfNotNull(
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
SincePerRelayFilter(
kinds = listOf(AppDefinitionEvent.KIND),
limit = 300,
tags = mapOf("k" to listOf("5300")),
since = since(key),
),
),
) : ComposeSubscriptionManager<DiscoveryQueryState>() {
val group =
listOf(
DiscoveryFollowsDiscoverySubAssembler(client, ::allKeys),
MixGeohashHashtagsDiscoverySubAssembler(client, ::allKeys),
)
fun createLiveStreamFilter(key: DiscoveryQueryState): List<TypedFilter> {
val follows =
key.account.liveDiscoveryFollowLists.value
?.authors
?.toList()
?.ifEmpty { null }
override fun start() = group.forEach { it.start() }
val followsRelays = key.account.liveDiscoveryListAuthorsPerRelay.value
override fun stop() = group.forEach { it.stop() }
return listOfNotNull(
TypedFilter(
types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS),
filter =
SinceAuthorPerRelayFilter(
authors = followsRelays,
kinds =
listOf(
LiveActivitiesChatMessageEvent.KIND,
LiveActivitiesEvent.KIND,
),
limit = 300,
since = since(key),
),
),
follows?.let {
TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
SincePerRelayFilter(
tags = mapOf("p" to it),
kinds = listOf(LiveActivitiesEvent.KIND),
limit = 100,
since = since(key),
),
)
},
)
}
override fun invalidateKeys() = invalidateFilters()
fun createPublicChatFilter(key: DiscoveryQueryState): List<TypedFilter> {
val follows =
key.account.liveDiscoveryListAuthorsPerRelay.value
?.ifEmpty { null }
val followChats =
key.account.publicChatList.livePublicChatEventIdSet.value
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
return listOfNotNull(
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
SinceAuthorPerRelayFilter(
authors = follows,
kinds = listOf(ChannelMessageEvent.KIND),
limit = 500,
since = since(key),
),
),
if (followChats.isNotEmpty()) {
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
SincePerRelayFilter(
ids = followChats.toList(),
kinds =
listOf(
ChannelCreateEvent.KIND,
ChannelMessageEvent.KIND,
),
limit = 300,
since = since(key),
),
)
} else {
null
},
)
}
override fun destroy() = group.forEach { it.start() }
fun createFollowSetFilter(key: DiscoveryQueryState): List<TypedFilter> {
val follows =
key.account.liveDiscoveryListAuthorsPerRelay.value
?.ifEmpty { null }
return listOfNotNull(
TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
SinceAuthorPerRelayFilter(
authors = follows,
kinds = listOf(FollowListEvent.KIND),
limit = 300,
since = since(key),
),
),
)
}
fun createLongFormFilter(key: DiscoveryQueryState): List<TypedFilter> {
val follows =
key.account.liveDiscoveryListAuthorsPerRelay.value
?.ifEmpty { null }
return listOfNotNull(
TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
SinceAuthorPerRelayFilter(
authors = follows,
kinds = listOf(LongTextNoteEvent.KIND),
limit = 300,
since = since(key),
),
),
)
}
fun createCommunitiesFilter(key: DiscoveryQueryState): TypedFilter {
val follows = key.account.liveDiscoveryListAuthorsPerRelay.value
return TypedFilter(
types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS),
filter =
SinceAuthorPerRelayFilter(
authors = follows,
kinds =
listOf(
CommunityDefinitionEvent.KIND,
CommunityPostApprovalEvent.KIND,
),
limit = 300,
since = since(key),
),
)
}
fun createLiveStreamTagsFilter(key: DiscoveryQueryState): TypedFilter? {
val hashToLoad =
key.account.liveDiscoveryFollowLists.value
?.hashtags
?.toList()
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
SincePerRelayFilter(
kinds =
listOf(
LiveActivitiesChatMessageEvent.KIND,
LiveActivitiesEvent.KIND,
),
tags =
mapOf(
"t" to
hashToLoad
.map {
listOf(
it,
it.lowercase(),
it.uppercase(),
it.replaceFirstChar {
if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString()
},
)
}.flatten(),
),
limit = 300,
since = since(key),
),
)
}
fun createLiveStreamGeohashesFilter(key: DiscoveryQueryState): TypedFilter? {
val hashToLoad =
key.account.liveDiscoveryFollowLists.value
?.geotags
?.toList()
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
SincePerRelayFilter(
kinds =
listOf(
LiveActivitiesChatMessageEvent.KIND,
LiveActivitiesEvent.KIND,
),
tags = mapOf("g" to hashToLoad),
limit = 300,
since = since(key),
),
)
}
fun createPublicChatsTagsFilter(key: DiscoveryQueryState): TypedFilter? {
val hashToLoad =
key.account.liveDiscoveryFollowLists.value
?.hashtags
?.toList()
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
SincePerRelayFilter(
kinds =
listOf(
ChannelCreateEvent.KIND,
ChannelMetadataEvent.KIND,
ChannelMessageEvent.KIND,
),
tags =
mapOf(
"t" to
hashToLoad
.map {
listOf(
it,
it.lowercase(),
it.uppercase(),
it.replaceFirstChar {
if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString()
},
)
}.flatten(),
),
limit = 300,
since = since(key),
),
)
}
fun createPublicChatsGeohashesFilter(key: DiscoveryQueryState): TypedFilter? {
val hashToLoad =
key.account.liveDiscoveryFollowLists.value
?.geotags
?.toList()
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
SincePerRelayFilter(
kinds =
listOf(
ChannelCreateEvent.KIND,
ChannelMetadataEvent.KIND,
ChannelMessageEvent.KIND,
),
tags =
mapOf("g" to hashToLoad),
limit = 300,
since = since(key),
),
)
}
fun createCommunitiesTagsFilter(key: DiscoveryQueryState): TypedFilter? {
val hashToLoad =
key.account.liveDiscoveryFollowLists.value
?.hashtags
?.toList()
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
SincePerRelayFilter(
kinds =
listOf(
CommunityDefinitionEvent.KIND,
CommunityPostApprovalEvent.KIND,
),
tags =
mapOf(
"t" to
hashToLoad
.map {
listOf(
it,
it.lowercase(),
it.uppercase(),
it.replaceFirstChar {
if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString()
},
)
}.flatten(),
),
limit = 300,
since = since(key),
),
)
}
fun createCommunitiesGeohashesFilter(key: DiscoveryQueryState): TypedFilter? {
val hashToLoad =
key.account.liveDiscoveryFollowLists.value
?.geotags
?.toList()
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
SincePerRelayFilter(
kinds =
listOf(
CommunityDefinitionEvent.KIND,
CommunityPostApprovalEvent.KIND,
),
tags = mapOf("g" to hashToLoad),
limit = 300,
since = since(key),
),
)
}
fun mergeAllFilters(key: DiscoveryQueryState): List<TypedFilter>? =
createLiveStreamFilter(key)
.plus(createNIP89Filter(key))
.plus(createPublicChatFilter(key))
.plus(createFollowSetFilter(key))
.plus(createLongFormFilter(key))
.plus(createMarketplaceFilter(key))
.plus(
listOfNotNull(
createLiveStreamTagsFilter(key),
createLiveStreamGeohashesFilter(key),
createCommunitiesFilter(key),
createCommunitiesTagsFilter(key),
createCommunitiesGeohashesFilter(key),
createPublicChatsTagsFilter(key),
createPublicChatsGeohashesFilter(key),
),
).toList()
.ifEmpty { null }
val userJobMap = mutableMapOf<User, Job>()
val userSubscriptionMap = mutableMapOf<User, String>()
fun newSub(key: DiscoveryQueryState): Subscription {
userJobMap[key.account.userProfile()]?.cancel()
userJobMap[key.account.userProfile()] =
key.scope.launch(Dispatchers.Default) {
key.account.liveDiscoveryFollowLists.collect {
invalidateFilters()
}
}
return requestNewSubscription { time, relayUrl ->
newEose(key, relayUrl, time)
}
}
fun endSub(
key: User,
subId: String,
) {
dismissSubscription(subId)
userJobMap[key]?.cancel()
}
fun findOrCreateSubFor(key: DiscoveryQueryState): Subscription {
var subId = userSubscriptionMap[key.account.userProfile()]
return if (subId == null) {
newSub(key).also { userSubscriptionMap[key.account.userProfile()] = it.id }
} else {
getSub(subId) ?: newSub(key).also { userSubscriptionMap[key.account.userProfile()] = it.id }
}
}
// One sub per subscribed account
override fun updateSubscriptions(keys: Set<DiscoveryQueryState>) {
val uniqueSubscribedAccounts = keys.distinctBy { it.account }
val updated = mutableSetOf<User>()
uniqueSubscribedAccounts.forEach {
val user = it.account.userProfile()
val sub = findOrCreateSubFor(it)
sub.typedFilters = mergeAllFilters(it)
updated.add(user)
}
userSubscriptionMap.forEach {
if (it.key !in updated) {
endSub(it.key, it.value)
}
}
}
override fun printStats() = group.forEach { it.printStats() }
}
@@ -0,0 +1,119 @@
/**
* Copyright (c) 2024 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.ui.screen.loggedIn.discover.datasource
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies.filterLongFormByFollows
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies.filterPublicChatsByFollows
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies.filterFollowSetsByFollows
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByFollows
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies.filterCommunitiesByFollows
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsByFollows
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies.filterClassifiedsByFollows
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.datasources.Subscription
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
import kotlin.collections.flatten
class DiscoveryFollowsDiscoverySubAssembler(
client: NostrClient,
allKeys: () -> Set<DiscoveryQueryState>,
) : PerUserAndFollowListEoseManager<DiscoveryQueryState>(client, allKeys) {
override fun updateFilter(
key: DiscoveryQueryState,
since: Map<String, EOSETime>?,
): List<TypedFilter>? = updateFilter(key.followsPerRelay(), key.followLists(), since)
override fun user(key: DiscoveryQueryState) = key.account.userProfile()
override fun list(key: DiscoveryQueryState) = key.listName()
fun DiscoveryQueryState.listNameFlow() = account.settings.defaultDiscoveryFollowList
fun DiscoveryQueryState.listName() = listNameFlow().value
fun DiscoveryQueryState.followListsFlow() = account.liveDiscoveryFollowLists
fun DiscoveryQueryState.followLists() = followListsFlow().value
fun DiscoveryQueryState.followsPerRelayFlow() = account.liveDiscoveryListAuthorsPerRelay
fun DiscoveryQueryState.followsPerRelay() = followsPerRelayFlow().value
fun updateFilter(
follows: Map<String, List<HexKey>>?,
followLists: Account.LiveFollowList?,
since: Map<String, EOSETime>?,
): List<TypedFilter>? {
if (follows != null && follows.isEmpty()) return null
return listOfNotNull(
filterClassifiedsByFollows(follows, since),
filterFollowSetsByFollows(follows, since),
filterLongFormByFollows(follows, since),
filterPublicChatsByFollows(follows, since),
filterContentDVMsByFollows(follows, since),
filterLiveActivitiesByFollows(follows, followLists?.authorsPlusMe, since),
filterCommunitiesByFollows(follows, since),
).flatten()
}
val userJobMap = mutableMapOf<User, List<Job>>()
@OptIn(FlowPreview::class)
override fun newSub(key: DiscoveryQueryState): Subscription {
val user = user(key)
userJobMap[user]?.forEach { it.cancel() }
userJobMap[user] =
listOf(
key.scope.launch(Dispatchers.Default) {
key.listNameFlow().collectLatest {
invalidateFilters()
}
},
key.scope.launch(Dispatchers.Default) {
key.followsPerRelayFlow().sample(5000).collectLatest {
invalidateFilters()
}
},
)
return super.newSub(key)
}
override fun endSub(
key: User,
subId: String,
) {
return super.endSub(key, subId)
userJobMap[key]?.forEach { it.cancel() }
}
}
@@ -0,0 +1,123 @@
/**
* Copyright (c) 2024 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.ui.screen.loggedIn.discover.datasource
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies.filterLongFormByGeohash
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies.filterLongFormByHashtag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies.filterPublicChatsByGeohash
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies.filterPublicChatsByHashtag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies.filterFollowSetsByGeohash
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies.filterFollowSetsByHashtag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByGeohash
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByHashtag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies.filterCommunityPostsByGeohash
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies.filterCommunityPostsByHashtag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsByGeohash
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsByHashtag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies.filterClassifiedsByGeohash
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies.filterClassifiedsByHashtag
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.datasources.Subscription
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
class MixGeohashHashtagsDiscoverySubAssembler(
client: NostrClient,
allKeys: () -> Set<DiscoveryQueryState>,
) : PerUserEoseManager<DiscoveryQueryState>(client, allKeys) {
override fun updateFilter(
key: DiscoveryQueryState,
since: Map<String, EOSETime>?,
): List<TypedFilter>? = updateFilter(key.followLists()?.hashtags, key.followLists()?.geotags, since)
fun updateFilter(
hashtags: Set<String>?,
geotags: Set<String>?,
since: Map<String, EOSETime>?,
): List<TypedFilter>? {
val hashtagFilters =
if (hashtags != null && !hashtags.isEmpty()) {
listOfNotNull(
filterClassifiedsByHashtag(hashtags, since),
filterPublicChatsByHashtag(hashtags, since),
filterFollowSetsByHashtag(hashtags, since),
filterLongFormByHashtag(hashtags, since),
filterContentDVMsByHashtag(hashtags, since),
filterLiveActivitiesByHashtag(hashtags, since),
filterCommunityPostsByHashtag(hashtags, since),
).flatten()
} else {
emptyList()
}
val geoHashFilters =
if (geotags != null && !geotags.isEmpty()) {
listOfNotNull(
filterClassifiedsByGeohash(geotags, since),
filterPublicChatsByGeohash(geotags, since),
filterFollowSetsByGeohash(geotags, since),
filterLongFormByGeohash(geotags, since),
filterContentDVMsByGeohash(geotags, since),
filterLiveActivitiesByGeohash(geotags, since),
filterCommunityPostsByGeohash(geotags, since),
).flatten()
} else {
emptyList()
}
return hashtagFilters + geoHashFilters
}
override fun user(key: DiscoveryQueryState) = key.account.userProfile()
fun DiscoveryQueryState.followListsFlow() = account.liveDiscoveryFollowLists
fun DiscoveryQueryState.followLists() = followListsFlow().value
val userJobMap = mutableMapOf<User, Job>()
override fun newSub(key: DiscoveryQueryState): Subscription {
val user = user(key)
userJobMap[user]?.cancel()
userJobMap[user] =
key.scope.launch(Dispatchers.Default) {
key.followListsFlow().collectLatest {
invalidateFilters()
}
}
return super.newSub(key)
}
override fun endSub(
key: User,
subId: String,
) {
return super.endSub(key, subId)
userJobMap[key]?.cancel()
}
}
@@ -0,0 +1,48 @@
/**
* Copyright (c) 2024 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.ui.screen.loggedIn.discover.nip23LongForm.subassemblies
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
fun filterLongFormByFollows(
follows: Map<String, List<HexKey>>?,
since: Map<String, EOSETime>?,
): List<TypedFilter>? {
if (follows != null && follows.isEmpty()) return null
return listOf(
TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
SinceAuthorPerRelayFilter(
authors = follows,
kinds = listOf(LongTextNoteEvent.KIND),
limit = 300,
since = since,
),
),
)
}
@@ -0,0 +1,49 @@
/**
* Copyright (c) 2024 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.ui.screen.loggedIn.discover.nip23LongForm.subassemblies
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
fun filterLongFormByGeohash(
hashToLoad: Set<String>?,
since: Map<String, EOSETime>?,
): List<TypedFilter>? {
if (hashToLoad == null || hashToLoad.isEmpty()) return null
val geoHashes = hashToLoad.toList()
return listOf(
TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
SincePerRelayFilter(
kinds = listOf(LongTextNoteEvent.KIND),
tags = mapOf("g" to geoHashes),
limit = 300,
since = since,
),
),
)
}
@@ -0,0 +1,53 @@
/**
* Copyright (c) 2024 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.ui.screen.loggedIn.discover.nip23LongForm.subassemblies
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
fun filterLongFormByHashtag(
hashToLoad: Set<String>?,
since: Map<String, EOSETime>?,
): List<TypedFilter>? {
if (hashToLoad == null || hashToLoad.isEmpty()) return null
val hashtags = hashtagAlts(hashToLoad).toList()
return listOf(
TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
SincePerRelayFilter(
kinds =
listOf(
LongTextNoteEvent.KIND,
),
tags = mapOf("t" to hashtags),
limit = 300,
since = since,
),
),
)
}
@@ -0,0 +1,48 @@
/**
* Copyright (c) 2024 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.ui.screen.loggedIn.discover.nip28Chats.subassemblies
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
fun filterPublicChatsByFollows(
follows: Map<String, List<HexKey>>?,
since: Map<String, EOSETime>?,
): List<TypedFilter>? {
if (follows != null && follows.isEmpty()) return null
return listOf(
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
SinceAuthorPerRelayFilter(
authors = follows,
kinds = listOf(ChannelMessageEvent.KIND),
limit = 500,
since = since,
),
),
)
}
@@ -0,0 +1,56 @@
/**
* Copyright (c) 2024 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.ui.screen.loggedIn.discover.nip28Chats.subassemblies
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
fun filterPublicChatsByGeohash(
hashToLoad: Set<String>?,
since: Map<String, EOSETime>?,
): List<TypedFilter>? {
if (hashToLoad == null || hashToLoad.isEmpty()) return null
val geoHashes = hashToLoad.toList()
return listOf(
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
SincePerRelayFilter(
kinds =
listOf(
ChannelCreateEvent.KIND,
ChannelMetadataEvent.KIND,
ChannelMessageEvent.KIND,
),
tags = mapOf("g" to geoHashes),
limit = 300,
since = since,
),
),
)
}
@@ -0,0 +1,57 @@
/**
* Copyright (c) 2024 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.ui.screen.loggedIn.discover.nip28Chats.subassemblies
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
fun filterPublicChatsByHashtag(
hashToLoad: Set<String>?,
since: Map<String, EOSETime>?,
): List<TypedFilter>? {
if (hashToLoad == null || hashToLoad.isEmpty()) return null
val hashtags = hashtagAlts(hashToLoad).toList()
return listOf(
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
SincePerRelayFilter(
kinds =
listOf(
ChannelCreateEvent.KIND,
ChannelMetadataEvent.KIND,
ChannelMessageEvent.KIND,
),
tags = mapOf("t" to hashtags),
limit = 300,
since = since,
),
),
)
}
@@ -0,0 +1,48 @@
/**
* Copyright (c) 2024 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.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip51Lists.FollowListEvent
fun filterFollowSetsByFollows(
follows: Map<String, List<HexKey>>?,
since: Map<String, EOSETime>?,
): List<TypedFilter>? {
if (follows != null && follows.isEmpty()) return null
return listOf(
TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
SinceAuthorPerRelayFilter(
authors = follows,
kinds = listOf(FollowListEvent.KIND),
limit = 300,
since = since,
),
),
)
}
@@ -0,0 +1,49 @@
/**
* Copyright (c) 2024 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.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
fun filterFollowSetsByGeohash(
hashToLoad: Set<String>?,
since: Map<String, EOSETime>?,
): List<TypedFilter>? {
if (hashToLoad == null || hashToLoad.isEmpty()) return null
val geoHashes = hashToLoad.toList()
return listOf(
TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
SincePerRelayFilter(
kinds = listOf(LongTextNoteEvent.KIND),
tags = mapOf("g" to geoHashes),
limit = 300,
since = since,
),
),
)
}
@@ -0,0 +1,53 @@
/**
* Copyright (c) 2024 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.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
fun filterFollowSetsByHashtag(
hashToLoad: Set<String>?,
since: Map<String, EOSETime>?,
): List<TypedFilter>? {
if (hashToLoad == null || hashToLoad.isEmpty()) return null
val hashtags = hashtagAlts(hashToLoad).toList()
return listOf(
TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
SincePerRelayFilter(
kinds =
listOf(
LongTextNoteEvent.KIND,
),
tags = mapOf("t" to hashtags),
limit = 300,
since = since,
),
),
)
}
@@ -0,0 +1,67 @@
/**
* Copyright (c) 2024 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.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
fun filterLiveActivitiesByFollows(
follows: Map<String, List<HexKey>>?,
followKeys: Set<String>?,
since: Map<String, EOSETime>?,
): List<TypedFilter>? {
if (follows != null && follows.isEmpty()) return null
return listOfNotNull(
TypedFilter(
types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS),
filter =
SinceAuthorPerRelayFilter(
authors = follows,
kinds =
listOf(
LiveActivitiesChatMessageEvent.KIND,
LiveActivitiesEvent.KIND,
),
limit = 300,
since = since,
),
),
followKeys?.let {
TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
SincePerRelayFilter(
tags = mapOf("p" to it.toList()),
kinds = listOf(LiveActivitiesEvent.KIND),
limit = 100,
since = since,
),
)
},
)
}
@@ -0,0 +1,54 @@
/**
* Copyright (c) 2024 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.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
fun filterLiveActivitiesByGeohash(
hashToLoad: Set<String>?,
since: Map<String, EOSETime>?,
): List<TypedFilter>? {
if (hashToLoad == null || hashToLoad.isEmpty()) return null
val geoHashes = hashToLoad.toList()
return listOf(
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
SincePerRelayFilter(
kinds =
listOf(
LiveActivitiesChatMessageEvent.KIND,
LiveActivitiesEvent.KIND,
),
tags = mapOf("g" to geoHashes),
limit = 300,
since = since,
),
),
)
}
@@ -0,0 +1,55 @@
/**
* Copyright (c) 2024 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.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
fun filterLiveActivitiesByHashtag(
hashToLoad: Set<String>?,
since: Map<String, EOSETime>?,
): List<TypedFilter>? {
if (hashToLoad == null || hashToLoad.isEmpty()) return null
val hashtags = hashtagAlts(hashToLoad).toList()
return listOf(
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
SincePerRelayFilter(
kinds =
listOf(
LiveActivitiesChatMessageEvent.KIND,
LiveActivitiesEvent.KIND,
),
tags = mapOf("t" to hashtags),
limit = 300,
since = since,
),
),
)
}

Some files were not shown because too many files have changed in this diff Show More