Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst: (48 commits) Use accountViewModel.viewModelScope instead of rememberCoroutineScope() to allow download/share to finish even when controls auto-close. Add content parameter to allow sharing of video from video player New Crowdin translations by GitHub Action - use a valid hex key - check the value of the tags Fixed NullPointerException when filter contain tags Fix assertEquals order fix Jackson deserialization for empty Filters and add regression test New Crowdin translations by GitHub Action Consume File.delete() return values Extract duplicated "https://" literal into a private const val HTTPS_PREFIX New Crowdin translations by GitHub Action New Crowdin translations by GitHub Action New Crowdin translations by GitHub Action Load and save payment targets refactor(commons): migrate from Moko to Compose Multiplatform Resources feat(broadcast): Enhance retry, multi-broadcast UI, and post tracking (#1682) wip: Add BroadcastTracker to AccountViewModel (#1682) feat(broadcast): Add transparent event broadcasting feedback (#1682) Enabled the reactions expand control for zapraisers even when there are no zaps New Crowdin translations by GitHub Action refactor to reduce complexity encodePcmToAac ... # Conflicts: # amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedState.kt
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.icons
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.PathFillType.Companion.NonZero
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.StrokeCap.Companion.Butt
|
||||
import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.path
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.ui.tooling.preview.Preview
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun VectorPreview() {
|
||||
Image(Bookmark, null)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun FilledPreview() {
|
||||
Image(BookmarkFilled, null)
|
||||
}
|
||||
|
||||
private var bookmark: ImageVector? = null
|
||||
private var bookmarkFilled: ImageVector? = null
|
||||
|
||||
val Bookmark: ImageVector
|
||||
get() =
|
||||
bookmark ?: ImageVector
|
||||
.Builder(
|
||||
name = "Bookmark",
|
||||
defaultWidth = 24.0.dp,
|
||||
defaultHeight = 24.0.dp,
|
||||
viewportWidth = 24.0f,
|
||||
viewportHeight = 24.0f,
|
||||
).apply {
|
||||
path(
|
||||
fill = SolidColor(Color(0xFF000000)),
|
||||
stroke = null,
|
||||
strokeLineWidth = 0.0f,
|
||||
strokeLineCap = Butt,
|
||||
strokeLineJoin = Miter,
|
||||
strokeLineMiter = 4.0f,
|
||||
pathFillType = NonZero,
|
||||
) {
|
||||
// Material bookmark outline
|
||||
moveTo(17.0f, 3.0f)
|
||||
horizontalLineTo(7.0f)
|
||||
curveToRelative(-1.1f, 0.0f, -2.0f, 0.9f, -2.0f, 2.0f)
|
||||
verticalLineToRelative(16.0f)
|
||||
lineToRelative(7.0f, -3.0f)
|
||||
lineToRelative(7.0f, 3.0f)
|
||||
verticalLineTo(5.0f)
|
||||
curveToRelative(0.0f, -1.1f, -0.9f, -2.0f, -2.0f, -2.0f)
|
||||
close()
|
||||
moveTo(17.0f, 18.0f)
|
||||
lineToRelative(-5.0f, -2.18f)
|
||||
lineTo(7.0f, 18.0f)
|
||||
verticalLineTo(5.0f)
|
||||
horizontalLineToRelative(10.0f)
|
||||
verticalLineToRelative(13.0f)
|
||||
close()
|
||||
}
|
||||
}.build()
|
||||
.also { bookmark = it }
|
||||
|
||||
val BookmarkFilled: ImageVector
|
||||
get() =
|
||||
bookmarkFilled ?: ImageVector
|
||||
.Builder(
|
||||
name = "BookmarkFilled",
|
||||
defaultWidth = 24.0.dp,
|
||||
defaultHeight = 24.0.dp,
|
||||
viewportWidth = 24.0f,
|
||||
viewportHeight = 24.0f,
|
||||
).apply {
|
||||
path(
|
||||
fill = SolidColor(Color(0xFF000000)),
|
||||
stroke = null,
|
||||
strokeLineWidth = 0.0f,
|
||||
strokeLineCap = Butt,
|
||||
strokeLineJoin = Miter,
|
||||
strokeLineMiter = 4.0f,
|
||||
pathFillType = NonZero,
|
||||
) {
|
||||
// Material bookmark filled
|
||||
moveTo(17.0f, 3.0f)
|
||||
horizontalLineTo(7.0f)
|
||||
curveToRelative(-1.1f, 0.0f, -2.0f, 0.9f, -2.0f, 2.0f)
|
||||
verticalLineToRelative(16.0f)
|
||||
lineToRelative(7.0f, -3.0f)
|
||||
lineToRelative(7.0f, 3.0f)
|
||||
verticalLineTo(5.0f)
|
||||
curveToRelative(0.0f, -1.1f, -0.9f, -2.0f, -2.0f, -2.0f)
|
||||
close()
|
||||
}
|
||||
}.build()
|
||||
.also { bookmarkFilled = it }
|
||||
Vendored
+22
@@ -112,5 +112,27 @@ interface ICacheProvider {
|
||||
*/
|
||||
fun hasBeenDeleted(event: Any): Boolean
|
||||
|
||||
/**
|
||||
* Finds users whose name, displayName, nip05, or lud16 starts with the given prefix.
|
||||
* Used by search functionality to find users by name.
|
||||
*
|
||||
* @param prefix The search prefix to match against user names
|
||||
* @param limit Maximum number of results to return
|
||||
* @return List of Users matching the prefix
|
||||
*/
|
||||
fun findUsersStartingWith(
|
||||
prefix: String,
|
||||
limit: Int = 50,
|
||||
): List<Any> = emptyList()
|
||||
|
||||
/**
|
||||
* Gets or creates a User by public key hex.
|
||||
* Used when processing events that reference users.
|
||||
*
|
||||
* @param pubkey The user's public key in hex format
|
||||
* @return The User (existing or newly created)
|
||||
*/
|
||||
fun getOrCreateUser(pubkey: HexKey): Any?
|
||||
|
||||
fun justConsumeMyOwnEvent(event: Event): Boolean
|
||||
}
|
||||
|
||||
-1
@@ -34,7 +34,6 @@ import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import java.lang.ref.WeakReference
|
||||
import kotlin.collections.plus
|
||||
|
||||
@Stable
|
||||
class Chatroom : NotesGatherer {
|
||||
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.assemblers
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataPreloader
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.PrioritizedSubscriptionQueue
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubscriptionPriority
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
/**
|
||||
* Coordinates metadata and reactions loading for feed items.
|
||||
* Ensures metadata (display names, avatars) loads before reactions.
|
||||
*
|
||||
* Priority order:
|
||||
* 1. METADATA - Display names, avatars (highest priority)
|
||||
* 2. REACTIONS - Likes, zaps, reposts (second priority)
|
||||
*
|
||||
* Usage:
|
||||
* ```
|
||||
* val coordinator = FeedMetadataCoordinator(client, scope, indexRelays, preloader)
|
||||
* coordinator.start()
|
||||
*
|
||||
* // When feed loads new notes
|
||||
* LaunchedEffect(notes) {
|
||||
* coordinator.loadMetadataForNotes(notes)
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
class FeedMetadataCoordinator(
|
||||
private val client: INostrClient,
|
||||
private val scope: CoroutineScope,
|
||||
private val indexRelays: Set<NormalizedRelayUrl>,
|
||||
private val preloader: MetadataPreloader? = null,
|
||||
private val onEvent: ((Event, NormalizedRelayUrl) -> Unit)? = null,
|
||||
) {
|
||||
private val priorityQueue = PrioritizedSubscriptionQueue(scope)
|
||||
|
||||
// Track what we've already queued to avoid duplicates
|
||||
private val queuedPubkeys = mutableSetOf<HexKey>()
|
||||
private val queuedNoteIds = mutableSetOf<HexKey>()
|
||||
|
||||
/**
|
||||
* Start processing the subscription queue.
|
||||
* Call once when coordinator is created.
|
||||
*/
|
||||
fun start() {
|
||||
priorityQueue.start { filter ->
|
||||
// Convert filter to relay-based map for all index relays
|
||||
val filterMap = indexRelays.associateWith { listOf(filter) }
|
||||
|
||||
// Create listener to pass events to the callback
|
||||
val listener =
|
||||
if (onEvent != null) {
|
||||
object : IRequestListener {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
onEvent.invoke(event, relay)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
client.openReqSubscription(
|
||||
subId = newSubId(),
|
||||
filters = filterMap,
|
||||
listener = listener,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load metadata and reactions for a list of notes.
|
||||
* Metadata loads first (priority 1), then reactions (priority 2).
|
||||
*
|
||||
* @param notes The notes to load metadata/reactions for
|
||||
*/
|
||||
fun loadMetadataForNotes(notes: List<Note>) {
|
||||
if (notes.isEmpty()) return
|
||||
|
||||
// Extract unique authors that we haven't already queued
|
||||
val authors =
|
||||
notes
|
||||
.mapNotNull { it.author?.pubkeyHex }
|
||||
.filter { it !in queuedPubkeys }
|
||||
.distinct()
|
||||
|
||||
// Extract unique note IDs that we haven't already queued
|
||||
val noteIds =
|
||||
notes
|
||||
.map { it.idHex }
|
||||
.filter { it !in queuedNoteIds }
|
||||
.distinct()
|
||||
|
||||
// Queue metadata first (highest priority)
|
||||
if (authors.isNotEmpty()) {
|
||||
queuedPubkeys.addAll(authors)
|
||||
|
||||
// Use preloader if available for rate-limited loading
|
||||
if (preloader != null) {
|
||||
notes.mapNotNull { it.author }.forEach { user ->
|
||||
preloader.preloadForUser(user)
|
||||
}
|
||||
} else {
|
||||
// Direct queue without rate limiting
|
||||
val metadataFilter =
|
||||
Filter(
|
||||
kinds = listOf(MetadataEvent.KIND),
|
||||
authors = authors,
|
||||
limit = authors.size,
|
||||
)
|
||||
priorityQueue.enqueue(
|
||||
SubscriptionPriority.METADATA,
|
||||
metadataFilter,
|
||||
tag = "feed-metadata",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Queue reactions second (lower priority)
|
||||
if (noteIds.isNotEmpty()) {
|
||||
queuedNoteIds.addAll(noteIds)
|
||||
|
||||
val reactionsFilter =
|
||||
Filter(
|
||||
kinds = listOf(ReactionEvent.KIND),
|
||||
tags = mapOf("e" to noteIds),
|
||||
)
|
||||
priorityQueue.enqueue(
|
||||
SubscriptionPriority.REACTIONS,
|
||||
reactionsFilter,
|
||||
tag = "feed-reactions",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load metadata for specific pubkeys.
|
||||
* Useful for loading follower/following metadata.
|
||||
*/
|
||||
fun loadMetadataForPubkeys(pubkeys: List<HexKey>) {
|
||||
val newPubkeys = pubkeys.filter { it !in queuedPubkeys }
|
||||
if (newPubkeys.isEmpty()) return
|
||||
|
||||
queuedPubkeys.addAll(newPubkeys)
|
||||
|
||||
val filter =
|
||||
Filter(
|
||||
kinds = listOf(MetadataEvent.KIND),
|
||||
authors = newPubkeys,
|
||||
limit = newPubkeys.size,
|
||||
)
|
||||
priorityQueue.enqueue(
|
||||
SubscriptionPriority.METADATA,
|
||||
filter,
|
||||
tag = "pubkey-metadata",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load reactions for specific note IDs.
|
||||
*/
|
||||
fun loadReactionsForNotes(noteIds: List<HexKey>) {
|
||||
val newNoteIds = noteIds.filter { it !in queuedNoteIds }
|
||||
if (newNoteIds.isEmpty()) return
|
||||
|
||||
queuedNoteIds.addAll(newNoteIds)
|
||||
|
||||
val filter =
|
||||
Filter(
|
||||
kinds = listOf(ReactionEvent.KIND),
|
||||
tags = mapOf("e" to newNoteIds),
|
||||
)
|
||||
priorityQueue.enqueue(
|
||||
SubscriptionPriority.REACTIONS,
|
||||
filter,
|
||||
tag = "note-reactions",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear queued items. Call when switching feeds.
|
||||
*/
|
||||
fun clear() {
|
||||
priorityQueue.clear()
|
||||
queuedPubkeys.clear()
|
||||
queuedNoteIds.clear()
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.assemblers
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager
|
||||
import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
/**
|
||||
* Query state for metadata subscriptions.
|
||||
* Groups pubkeys with their preferred index relays.
|
||||
*/
|
||||
data class MetadataQueryState(
|
||||
val pubkeys: Set<HexKey>,
|
||||
val indexRelays: Set<NormalizedRelayUrl>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Subscribes to Kind 0 (user metadata) for a set of pubkeys.
|
||||
* Used to load display names, avatars, and other profile information.
|
||||
*
|
||||
* This assembler:
|
||||
* - Batches multiple pubkey requests into single subscription
|
||||
* - Sends requests to index relays for efficient discovery
|
||||
* - Caches EOSE to avoid re-fetching known metadata
|
||||
*/
|
||||
class MetadataFilterAssembler(
|
||||
client: INostrClient,
|
||||
allKeys: () -> Set<MetadataQueryState>,
|
||||
) : SingleSubEoseManager<MetadataQueryState>(client, allKeys, invalidateAfterEose = true) {
|
||||
override fun distinct(key: MetadataQueryState): Any = key.pubkeys.hashCode()
|
||||
|
||||
override fun updateFilter(
|
||||
keys: List<MetadataQueryState>,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter>? {
|
||||
// Collect all pubkeys and relays from all query states
|
||||
val allPubkeys = mutableSetOf<HexKey>()
|
||||
val allRelays = mutableSetOf<NormalizedRelayUrl>()
|
||||
|
||||
keys.forEach { state ->
|
||||
allPubkeys.addAll(state.pubkeys)
|
||||
allRelays.addAll(state.indexRelays)
|
||||
}
|
||||
|
||||
if (allPubkeys.isEmpty() || allRelays.isEmpty()) return null
|
||||
|
||||
val pubkeyList = allPubkeys.toList()
|
||||
|
||||
// Create filter for metadata (Kind 0)
|
||||
val filter =
|
||||
Filter(
|
||||
kinds = listOf(MetadataEvent.KIND),
|
||||
authors = pubkeyList,
|
||||
limit = pubkeyList.size,
|
||||
)
|
||||
|
||||
// Apply since times per relay
|
||||
return allRelays.map { relay ->
|
||||
val sinceTime = since?.get(relay)?.time
|
||||
val filterWithSince =
|
||||
if (sinceTime != null) {
|
||||
filter.copy(since = sinceTime)
|
||||
} else {
|
||||
filter
|
||||
}
|
||||
RelayBasedFilter(relay, filterWithSince)
|
||||
}
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.assemblers
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager
|
||||
import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
|
||||
/**
|
||||
* Query state for reactions subscriptions.
|
||||
* Groups note IDs with their preferred relays.
|
||||
*/
|
||||
data class ReactionsQueryState(
|
||||
val noteIds: Set<HexKey>,
|
||||
val relays: Set<NormalizedRelayUrl>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Subscribes to Kind 7 (reactions) for a set of note IDs.
|
||||
* Used to load like counts, zap counts, and other reactions.
|
||||
*
|
||||
* This assembler:
|
||||
* - Batches multiple note ID requests into single subscription
|
||||
* - Uses e-tags to filter reactions for specific notes
|
||||
* - Caches EOSE to avoid re-fetching known reactions
|
||||
*/
|
||||
class ReactionsFilterAssembler(
|
||||
client: INostrClient,
|
||||
allKeys: () -> Set<ReactionsQueryState>,
|
||||
) : SingleSubEoseManager<ReactionsQueryState>(client, allKeys, invalidateAfterEose = true) {
|
||||
override fun distinct(key: ReactionsQueryState): Any = key.noteIds.hashCode()
|
||||
|
||||
override fun updateFilter(
|
||||
keys: List<ReactionsQueryState>,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter>? {
|
||||
// Collect all note IDs and relays from all query states
|
||||
val allNoteIds = mutableSetOf<HexKey>()
|
||||
val allRelays = mutableSetOf<NormalizedRelayUrl>()
|
||||
|
||||
keys.forEach { state ->
|
||||
allNoteIds.addAll(state.noteIds)
|
||||
allRelays.addAll(state.relays)
|
||||
}
|
||||
|
||||
if (allNoteIds.isEmpty() || allRelays.isEmpty()) return null
|
||||
|
||||
val noteIdList = allNoteIds.toList()
|
||||
|
||||
// Create filter for reactions (Kind 7) targeting these notes via e-tags
|
||||
val filter =
|
||||
Filter(
|
||||
kinds = listOf(ReactionEvent.KIND),
|
||||
tags = mapOf("e" to noteIdList),
|
||||
)
|
||||
|
||||
// Apply since times per relay
|
||||
return allRelays.map { relay ->
|
||||
val sinceTime = since?.get(relay)?.time
|
||||
val filterWithSince =
|
||||
if (sinceTime != null) {
|
||||
filter.copy(since = sinceTime)
|
||||
} else {
|
||||
filter
|
||||
}
|
||||
RelayBasedFilter(relay, filterWithSince)
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* This allows composables to directly register their queries
|
||||
* to relays. There may be multiple duplications in these
|
||||
* subscriptions since we do not control when screens are removed.
|
||||
*/
|
||||
abstract class ComposeSubscriptionManager<T> : ComposeSubscriptionManagerControls {
|
||||
private var composeSubscriptions: ConcurrentHashMap<T, T> = ConcurrentHashMap()
|
||||
|
||||
// This is called by main. Keep it really fast.
|
||||
fun subscribe(query: T?) {
|
||||
if (query == null) return
|
||||
|
||||
composeSubscriptions.put(query, query)
|
||||
|
||||
invalidateKeys()
|
||||
}
|
||||
|
||||
// This is called by main. Keep it really fast.
|
||||
fun unsubscribe(query: T?) {
|
||||
if (query == null) return
|
||||
|
||||
composeSubscriptions.remove(query)
|
||||
|
||||
invalidateKeys()
|
||||
}
|
||||
|
||||
fun allKeys() = composeSubscriptions.keys
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers
|
||||
|
||||
interface ComposeSubscriptionManagerControls {
|
||||
fun invalidateKeys()
|
||||
|
||||
fun invalidateFilters()
|
||||
|
||||
fun destroy()
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* This allows composables to directly register their queries
|
||||
* to relays. There may be multiple duplications in these
|
||||
* subscriptions since we do not control when screens are removed.
|
||||
*
|
||||
* This is similar to QueryBasedSubscriptionOrchestrator, but it
|
||||
* also allows the subscription itself to change over time as a
|
||||
* flow, which trigger an update on the relay subscriptions
|
||||
*/
|
||||
abstract class MutableComposeSubscriptionManager<T : MutableQueryState>(
|
||||
val scope: CoroutineScope,
|
||||
) : ComposeSubscriptionManagerControls {
|
||||
private var composeSubscriptions: ConcurrentHashMap<T, Job?> = ConcurrentHashMap()
|
||||
|
||||
// This is called by main. Keep it really fast.
|
||||
fun subscribe(query: T?) {
|
||||
if (query == null) return
|
||||
|
||||
composeSubscriptions[query]?.cancel()
|
||||
composeSubscriptions[query] =
|
||||
scope.launch {
|
||||
query.flow().collectLatest {
|
||||
invalidateKeys()
|
||||
}
|
||||
}
|
||||
|
||||
invalidateKeys()
|
||||
}
|
||||
|
||||
// This is called by main. Keep it really fast.
|
||||
fun unsubscribe(query: T?) {
|
||||
if (query == null) return
|
||||
|
||||
composeSubscriptions[query]?.cancel()
|
||||
composeSubscriptions.remove(query)
|
||||
|
||||
invalidateKeys()
|
||||
}
|
||||
|
||||
fun allKeys() = composeSubscriptions.keys
|
||||
|
||||
fun forEachSubscriber(action: (T) -> Unit) {
|
||||
composeSubscriptions.keys.forEach(action)
|
||||
}
|
||||
}
|
||||
|
||||
interface MutableQueryState {
|
||||
fun flow(): Flow<*>
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.eoseManagers
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.service.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.commons.utils.isDebug
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.SubscriptionController
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
||||
abstract class BaseEoseManager<T>(
|
||||
val client: INostrClient,
|
||||
val allKeys: () -> Set<T>,
|
||||
val sampleTime: Long = 500,
|
||||
) : IEoseManager {
|
||||
protected val logTag: String = this::class.simpleName ?: "BaseEoseManager"
|
||||
|
||||
private val orchestrator = SubscriptionController(client)
|
||||
|
||||
abstract fun updateSubscriptions(keys: Set<T>)
|
||||
|
||||
fun newSubscriptionId() = if (isDebug) logTag + newSubId() else newSubId()
|
||||
|
||||
fun getSubscription(subId: String) = orchestrator.getSub(subId)
|
||||
|
||||
fun requestNewSubscription(listener: IRequestListener) = orchestrator.requestNewSubscription(newSubscriptionId(), listener)
|
||||
|
||||
fun dismissSubscription(subId: String) = orchestrator.dismissSubscription(subId)
|
||||
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(sampleTime, Dispatchers.IO)
|
||||
|
||||
override fun invalidateFilters(ignoreIfDoing: Boolean) {
|
||||
bundler.invalidate(ignoreIfDoing, ::forceInvalidate)
|
||||
}
|
||||
|
||||
fun forceInvalidate() {
|
||||
updateSubscriptions(allKeys())
|
||||
orchestrator.updateRelays()
|
||||
}
|
||||
|
||||
override fun destroy() {
|
||||
bundler.cancel()
|
||||
if (isDebug) {
|
||||
Log.d(logTag, "Destroy, Unsubscribe")
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.eoseManagers
|
||||
|
||||
interface IEoseManager {
|
||||
fun invalidateFilters(ignoreIfDoing: Boolean = false)
|
||||
|
||||
fun destroy()
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.eoseManagers
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relays.EOSECache
|
||||
import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* Generic per-key EOSE manager that creates a subscription for each unique key.
|
||||
*
|
||||
* This query type creates a new relay subscription for every distinct key K
|
||||
* extracted from query state T. It is ideal for screens that need separate
|
||||
* subscriptions per entity (user, thread, etc.).
|
||||
*
|
||||
* @param T The query state type (e.g., ThreadQueryState)
|
||||
* @param K The key type used to deduplicate subscriptions (e.g., String noteId)
|
||||
*
|
||||
* This class keeps EOSEs for each key for as long as possible and can be
|
||||
* shared among multiple query states that map to the same key.
|
||||
*/
|
||||
abstract class PerKeyEoseManager<T, K : Any>(
|
||||
client: INostrClient,
|
||||
allKeys: () -> Set<T>,
|
||||
val invalidateAfterEose: Boolean = false,
|
||||
cacheSize: Int = 200,
|
||||
) : BaseEoseManager<T>(client, allKeys) {
|
||||
// EOSE cache keyed by K
|
||||
private val latestEOSEs = EOSECache<K>(cacheSize)
|
||||
|
||||
// Map from key K to subscription ID
|
||||
private val keySubscriptionMap = mutableMapOf<K, String>()
|
||||
|
||||
/**
|
||||
* Get the since map for a query state's key.
|
||||
*/
|
||||
fun since(queryState: T): SincePerRelayMap? = latestEOSEs.since(extractKey(queryState))
|
||||
|
||||
/**
|
||||
* Record a new EOSE for a query state.
|
||||
*/
|
||||
open fun newEose(
|
||||
queryState: T,
|
||||
relayUrl: NormalizedRelayUrl,
|
||||
time: Long,
|
||||
filters: List<Filter>? = null,
|
||||
) {
|
||||
latestEOSEs.newEose(extractKey(queryState), relayUrl, time)
|
||||
if (invalidateAfterEose) {
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new subscription for a query state.
|
||||
*/
|
||||
open fun newSub(queryState: T): Subscription =
|
||||
requestNewSubscription(
|
||||
object : IRequestListener {
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
newEose(queryState, relay, TimeUtils.now(), forFilters)
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
if (isLive) {
|
||||
newEose(queryState, relay, TimeUtils.now(), forFilters)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* End a subscription for a key.
|
||||
*/
|
||||
open fun endSub(
|
||||
key: K,
|
||||
subId: String,
|
||||
) {
|
||||
dismissSubscription(subId)
|
||||
keySubscriptionMap.remove(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find or create a subscription for a query state.
|
||||
*/
|
||||
fun findOrCreateSubFor(queryState: T): Subscription {
|
||||
val key = extractKey(queryState)
|
||||
val subId = keySubscriptionMap[key]
|
||||
return if (subId == null) {
|
||||
newSub(queryState).also { keySubscriptionMap[key] = it.id }
|
||||
} else {
|
||||
getSubscription(subId) ?: newSub(queryState).also { keySubscriptionMap[key] = it.id }
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateSubscriptions(keys: Set<T>) {
|
||||
val uniqueByKey = keys.distinctBy { extractKey(it) }
|
||||
|
||||
val updatedKeys = mutableSetOf<K>()
|
||||
|
||||
uniqueByKey.forEach { queryState ->
|
||||
val key = extractKey(queryState)
|
||||
val newFilters = updateFilter(queryState, since(queryState))?.ifEmpty { null }
|
||||
findOrCreateSubFor(queryState).updateFilters(newFilters?.groupByRelay())
|
||||
updatedKeys.add(key)
|
||||
}
|
||||
|
||||
// Clean up subscriptions for keys no longer active
|
||||
keySubscriptionMap.filter { it.key !in updatedKeys }.forEach {
|
||||
endSub(it.key, it.value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build filters for a query state.
|
||||
* @return List of relay-based filters, or null to clear the subscription
|
||||
*/
|
||||
abstract fun updateFilter(
|
||||
queryState: T,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter>?
|
||||
|
||||
/**
|
||||
* Extract the deduplication key from a query state.
|
||||
* Subscriptions are shared among query states with the same key.
|
||||
*/
|
||||
abstract fun extractKey(queryState: T): K
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.eoseManagers
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relays.EOSERelayList
|
||||
import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* This query type creates only ONE relay subscription. It filters duplicates
|
||||
* by DistinctById.uniqueId() but it doesn't create a new subscription for each.
|
||||
*
|
||||
* All filters are passed as a single sub.
|
||||
*
|
||||
* It is ideal for temporary filters, including event, user finding that must be
|
||||
* disabled after the user is found.
|
||||
*
|
||||
* This class keeps EOSEs for as long as possible and
|
||||
* shares all EOSEs among all users.
|
||||
*/
|
||||
abstract class SingleSubEoseManager<T>(
|
||||
client: INostrClient,
|
||||
allKeys: () -> Set<T>,
|
||||
val invalidateAfterEose: Boolean = false,
|
||||
) : BaseEoseManager<T>(client, allKeys) {
|
||||
// long term EOSE cache
|
||||
private val latestEOSEs = EOSERelayList()
|
||||
|
||||
fun since() = latestEOSEs.since()
|
||||
|
||||
open fun newEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
time: Long,
|
||||
filters: List<Filter>? = null,
|
||||
) {
|
||||
latestEOSEs.newEose(relay, time)
|
||||
if (invalidateAfterEose) {
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
val sub =
|
||||
requestNewSubscription(
|
||||
object : IRequestListener {
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
newEose(relay, TimeUtils.now(), forFilters)
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
if (isLive) {
|
||||
newEose(relay, TimeUtils.now(), forFilters)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
override fun updateSubscriptions(keys: Set<T>) {
|
||||
val uniqueSubscribedAccounts = keys.distinctBy { distinct(it) }
|
||||
val newFilters = updateFilter(uniqueSubscribedAccounts, since())?.ifEmpty { null }
|
||||
|
||||
sub.updateFilters(newFilters?.groupByRelay())
|
||||
}
|
||||
|
||||
abstract fun updateFilter(
|
||||
keys: List<T>,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter>?
|
||||
|
||||
abstract fun distinct(key: T): Any
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.preload
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
|
||||
/**
|
||||
* Preloads user metadata and avatar images for feed items.
|
||||
* Coordinates between metadata subscription (rate-limited) and image preloading.
|
||||
*
|
||||
* Priority order:
|
||||
* 1. Metadata (display names, avatar URLs) - FIRST
|
||||
* 2. Avatar images (prefetch when metadata arrives) - SECOND
|
||||
*/
|
||||
class MetadataPreloader(
|
||||
private val rateLimiter: MetadataRateLimiter,
|
||||
private val imagePrefetcher: ImagePrefetcher? = null,
|
||||
) {
|
||||
/**
|
||||
* Queue users for metadata preloading.
|
||||
* If user already has metadata, prefetch their avatar image.
|
||||
* Otherwise, queue for metadata subscription.
|
||||
*/
|
||||
fun preloadForUsers(users: Collection<User>) {
|
||||
users.forEach { user ->
|
||||
val metadata = user.info
|
||||
if (metadata != null) {
|
||||
// Already have metadata, prefetch avatar
|
||||
metadata.picture?.let { avatarUrl ->
|
||||
imagePrefetcher?.prefetch(avatarUrl)
|
||||
}
|
||||
} else {
|
||||
// Need to fetch metadata first
|
||||
rateLimiter.enqueue(user.pubkeyHex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a single user for metadata preloading.
|
||||
*/
|
||||
fun preloadForUser(user: User) {
|
||||
val metadata = user.info
|
||||
if (metadata != null) {
|
||||
metadata.picture?.let { avatarUrl ->
|
||||
imagePrefetcher?.prefetch(avatarUrl)
|
||||
}
|
||||
} else {
|
||||
rateLimiter.enqueue(user.pubkeyHex)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when metadata arrives for a user.
|
||||
* Triggers avatar image prefetch.
|
||||
*/
|
||||
fun onMetadataReceived(user: User) {
|
||||
user.info?.picture?.let { avatarUrl ->
|
||||
imagePrefetcher?.prefetch(avatarUrl)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch avatar images for users that already have metadata.
|
||||
*/
|
||||
fun prefetchAvatars(users: Collection<User>) {
|
||||
users.forEach { user ->
|
||||
user.info?.picture?.let { avatarUrl ->
|
||||
imagePrefetcher?.prefetch(avatarUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for image prefetching.
|
||||
* Platform-specific implementations use Coil (Android) or similar (Desktop).
|
||||
*/
|
||||
interface ImagePrefetcher {
|
||||
/**
|
||||
* Prefetch an image URL into the cache.
|
||||
*/
|
||||
fun prefetch(url: String)
|
||||
|
||||
/**
|
||||
* Prefetch multiple image URLs.
|
||||
*/
|
||||
fun prefetchAll(urls: Collection<String>) {
|
||||
urls.forEach { prefetch(it) }
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.preload
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.consumeAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Global rate limiter for metadata requests to prevent thundering herd on fast scroll.
|
||||
* Batches pubkeys and processes at a controlled rate.
|
||||
*
|
||||
* @param maxRequestsPerSecond Maximum requests to process per second (default 20)
|
||||
* @param scope CoroutineScope for processing
|
||||
*/
|
||||
class MetadataRateLimiter(
|
||||
private val maxRequestsPerSecond: Int = 20,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val queue = Channel<String>(Channel.BUFFERED)
|
||||
private val processed = mutableSetOf<String>()
|
||||
|
||||
/**
|
||||
* Enqueue a pubkey for metadata fetching.
|
||||
* Duplicates within the same batch are automatically filtered.
|
||||
*/
|
||||
fun enqueue(pubkey: String) {
|
||||
if (pubkey !in processed) {
|
||||
queue.trySend(pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue multiple pubkeys for metadata fetching.
|
||||
*/
|
||||
fun enqueueAll(pubkeys: Collection<String>) {
|
||||
pubkeys.forEach { enqueue(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Start processing the queue with rate limiting.
|
||||
* @param onRequest Callback invoked for each pubkey to process
|
||||
*/
|
||||
fun start(onRequest: suspend (String) -> Unit) {
|
||||
scope.launch {
|
||||
val batch = mutableListOf<String>()
|
||||
queue.consumeAsFlow().collect { pubkey ->
|
||||
if (pubkey !in processed) {
|
||||
batch.add(pubkey)
|
||||
processed.add(pubkey)
|
||||
}
|
||||
|
||||
// Process batch when we hit the limit or queue is empty
|
||||
if (batch.size >= maxRequestsPerSecond) {
|
||||
processBatch(batch, onRequest)
|
||||
batch.clear()
|
||||
delay(1000) // Wait 1 second before next batch
|
||||
}
|
||||
}
|
||||
|
||||
// Process remaining
|
||||
if (batch.isNotEmpty()) {
|
||||
processBatch(batch, onRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun processBatch(
|
||||
batch: List<String>,
|
||||
onRequest: suspend (String) -> Unit,
|
||||
) {
|
||||
batch.forEach { pubkey ->
|
||||
onRequest(pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the processed set to allow re-fetching.
|
||||
* Call this when switching accounts or clearing cache.
|
||||
*/
|
||||
fun reset() {
|
||||
processed.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a pubkey has already been processed.
|
||||
*/
|
||||
fun isProcessed(pubkey: String): Boolean = pubkey in processed
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.subscriptions
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.MutableComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.MutableQueryState
|
||||
|
||||
@Composable
|
||||
fun <T> KeyDataSourceSubscription(
|
||||
state: T,
|
||||
dataSource: ComposeSubscriptionManager<T>,
|
||||
) = DisposableEffect(state) {
|
||||
dataSource.subscribe(state)
|
||||
onDispose {
|
||||
dataSource.unsubscribe(state)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T : MutableQueryState> KeyDataSourceSubscription(
|
||||
state: T,
|
||||
dataSource: MutableComposeSubscriptionManager<T>,
|
||||
) = DisposableEffect(state) {
|
||||
dataSource.subscribe(state)
|
||||
onDispose {
|
||||
dataSource.unsubscribe(state)
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.subscriptions
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.consumeAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* A subscription request with its priority.
|
||||
*/
|
||||
data class PrioritizedFilter(
|
||||
val priority: SubscriptionPriority,
|
||||
val filter: Filter,
|
||||
val tag: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Queue that processes subscription requests in priority order.
|
||||
* Ensures metadata loads before reactions, reactions before replies, etc.
|
||||
*
|
||||
* Usage:
|
||||
* ```
|
||||
* val queue = PrioritizedSubscriptionQueue(scope)
|
||||
* queue.start { filter -> relayClient.subscribe(filter) }
|
||||
*
|
||||
* // Add subscriptions - they'll be processed in priority order
|
||||
* queue.enqueue(SubscriptionPriority.METADATA, metadataFilter)
|
||||
* queue.enqueue(SubscriptionPriority.REACTIONS, reactionsFilter)
|
||||
* ```
|
||||
*/
|
||||
class PrioritizedSubscriptionQueue(
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
// Separate channels per priority for efficient processing
|
||||
private val queues =
|
||||
SubscriptionPriority.entries.associateWith {
|
||||
Channel<PrioritizedFilter>(Channel.BUFFERED)
|
||||
}
|
||||
|
||||
private var isRunning = false
|
||||
|
||||
/**
|
||||
* Enqueue a filter with the given priority.
|
||||
*/
|
||||
fun enqueue(
|
||||
priority: SubscriptionPriority,
|
||||
filter: Filter,
|
||||
tag: String? = null,
|
||||
) {
|
||||
queues[priority]?.trySend(PrioritizedFilter(priority, filter, tag))
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue multiple filters with the same priority.
|
||||
*/
|
||||
fun enqueueAll(
|
||||
priority: SubscriptionPriority,
|
||||
filters: List<Filter>,
|
||||
tag: String? = null,
|
||||
) {
|
||||
filters.forEach { enqueue(priority, it, tag) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Start processing the queue.
|
||||
* Processes higher priority items first within each batch.
|
||||
*
|
||||
* @param onSubscribe Callback to execute the subscription
|
||||
*/
|
||||
fun start(onSubscribe: suspend (Filter) -> Unit) {
|
||||
if (isRunning) return
|
||||
isRunning = true
|
||||
|
||||
// Process each priority queue in order
|
||||
SubscriptionPriority.sortedByPriority().forEach { priority ->
|
||||
scope.launch {
|
||||
queues[priority]?.consumeAsFlow()?.collect { item ->
|
||||
onSubscribe(item.filter)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all pending items immediately in priority order.
|
||||
* Useful for batch operations.
|
||||
*/
|
||||
suspend fun flush(onSubscribe: suspend (Filter) -> Unit) {
|
||||
SubscriptionPriority.sortedByPriority().forEach { priority ->
|
||||
val queue = queues[priority] ?: return@forEach
|
||||
while (true) {
|
||||
val item = queue.tryReceive().getOrNull() ?: break
|
||||
onSubscribe(item.filter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all pending items.
|
||||
*/
|
||||
fun clear() {
|
||||
queues.values.forEach { channel ->
|
||||
while (channel.tryReceive().isSuccess) {
|
||||
// Drain the channel
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.subscriptions
|
||||
|
||||
/**
|
||||
* Priority levels for relay subscriptions.
|
||||
* Lower order = higher priority = processed first.
|
||||
*
|
||||
* Priority order ensures progressive rendering:
|
||||
* 1. METADATA - Display names, avatars load first
|
||||
* 2. REACTIONS - Like/zap counts load second
|
||||
* 3. REPLIES - Reply counts
|
||||
* 4. CONTENT - Additional content
|
||||
*/
|
||||
enum class SubscriptionPriority(
|
||||
val order: Int,
|
||||
) {
|
||||
/** User metadata (Kind 0) - display names, avatars. Highest priority. */
|
||||
METADATA(1),
|
||||
|
||||
/** Reactions (Kind 7) - likes, zaps, reposts */
|
||||
REACTIONS(2),
|
||||
|
||||
/** Replies - reply counts and threads */
|
||||
REPLIES(3),
|
||||
|
||||
/** Additional content - lower priority items */
|
||||
CONTENT(4),
|
||||
;
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Get priorities sorted by order (highest priority first).
|
||||
*/
|
||||
fun sortedByPriority(): List<SubscriptionPriority> = entries.sortedBy { it.order }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.relays
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
/**
|
||||
* Generic EOSE cache keyed by any type K.
|
||||
* KMP-compatible version (no LruCache dependency).
|
||||
*
|
||||
* For memory-constrained environments, consider using platform-specific
|
||||
* LRU implementations in androidMain/jvmMain.
|
||||
*/
|
||||
open class EOSECache<K : Any>(
|
||||
private val maxSize: Int = 200,
|
||||
) {
|
||||
private val cache = linkedMapOf<K, EOSERelayList>()
|
||||
private val lock = Any()
|
||||
|
||||
fun addOrUpdate(
|
||||
key: K,
|
||||
relayUrl: NormalizedRelayUrl,
|
||||
time: Long,
|
||||
) {
|
||||
synchronized(lock) {
|
||||
val relayList = cache[key]
|
||||
if (relayList == null) {
|
||||
// Evict oldest if at capacity
|
||||
if (cache.size >= maxSize) {
|
||||
cache.remove(cache.keys.first())
|
||||
}
|
||||
val newList = EOSERelayList()
|
||||
newList.addOrUpdate(relayUrl, time)
|
||||
cache[key] = newList
|
||||
} else {
|
||||
relayList.addOrUpdate(relayUrl, time)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun since(key: K): SincePerRelayMap? =
|
||||
synchronized(lock) {
|
||||
cache[key]?.relayList?.toMutableMap()
|
||||
}
|
||||
|
||||
fun newEose(
|
||||
key: K,
|
||||
relayUrl: NormalizedRelayUrl,
|
||||
time: Long,
|
||||
) = addOrUpdate(key, relayUrl, time)
|
||||
|
||||
fun remove(key: K) {
|
||||
synchronized(lock) {
|
||||
cache.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
synchronized(lock) {
|
||||
cache.clear()
|
||||
}
|
||||
}
|
||||
|
||||
fun size(): Int = synchronized(lock) { cache.size }
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-level EOSE cache: outer key -> inner key -> relay list.
|
||||
* Useful for user + list combinations.
|
||||
*/
|
||||
open class EOSETwoLevelCache<K1 : Any, K2 : Any>(
|
||||
private val outerMaxSize: Int = 20,
|
||||
private val innerMaxSize: Int = 200,
|
||||
) {
|
||||
private val cache = linkedMapOf<K1, EOSECache<K2>>()
|
||||
private val lock = Any()
|
||||
|
||||
fun addOrUpdate(
|
||||
outerKey: K1,
|
||||
innerKey: K2,
|
||||
relayUrl: NormalizedRelayUrl,
|
||||
time: Long,
|
||||
) {
|
||||
synchronized(lock) {
|
||||
val innerCache = cache[outerKey]
|
||||
if (innerCache == null) {
|
||||
// Evict oldest if at capacity
|
||||
if (cache.size >= outerMaxSize) {
|
||||
cache.remove(cache.keys.first())
|
||||
}
|
||||
val newCache = EOSECache<K2>(innerMaxSize)
|
||||
newCache.addOrUpdate(innerKey, relayUrl, time)
|
||||
cache[outerKey] = newCache
|
||||
} else {
|
||||
innerCache.addOrUpdate(innerKey, relayUrl, time)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun since(
|
||||
outerKey: K1,
|
||||
innerKey: K2,
|
||||
): SincePerRelayMap? =
|
||||
synchronized(lock) {
|
||||
cache[outerKey]?.since(innerKey)
|
||||
}
|
||||
|
||||
fun newEose(
|
||||
outerKey: K1,
|
||||
innerKey: K2,
|
||||
relayUrl: NormalizedRelayUrl,
|
||||
time: Long,
|
||||
) = addOrUpdate(outerKey, innerKey, relayUrl, time)
|
||||
|
||||
fun removeOuter(key: K1) {
|
||||
synchronized(lock) {
|
||||
cache.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
synchronized(lock) {
|
||||
cache.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
-1
@@ -20,7 +20,6 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.richtext
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import java.util.Base64
|
||||
|
||||
object Base64Image {
|
||||
|
||||
-1
@@ -45,7 +45,6 @@ import java.net.MalformedURLException
|
||||
import java.net.URISyntaxException
|
||||
import java.net.URL
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.text.iterator
|
||||
|
||||
class RichTextParser {
|
||||
fun createMediaContent(
|
||||
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.search
|
||||
|
||||
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
|
||||
/**
|
||||
* Parses search input and returns matching results.
|
||||
* Supports: npub, nprofile, nsec (extracts pubkey), note, nevent, naddr, hex keys, hashtags
|
||||
*
|
||||
* Shared between Android and Desktop for consistent Bech32 parsing.
|
||||
*/
|
||||
fun parseSearchInput(input: String): List<SearchResult> {
|
||||
if (input.isBlank()) return emptyList()
|
||||
|
||||
val trimmed = input.trim()
|
||||
val results = mutableListOf<SearchResult>()
|
||||
|
||||
// Check for hashtag
|
||||
if (trimmed.startsWith("#") && trimmed.length > 1) {
|
||||
results.add(SearchResult.HashtagResult(trimmed.substring(1)))
|
||||
return results
|
||||
}
|
||||
|
||||
// Try to parse as Bech32 (npub, nevent, naddr, etc.)
|
||||
val parsed = Nip19Parser.uriToRoute(trimmed)?.entity
|
||||
if (parsed != null) {
|
||||
when (parsed) {
|
||||
is NPub -> {
|
||||
results.add(
|
||||
SearchResult.UserResult(
|
||||
pubKeyHex = parsed.hex,
|
||||
displayId = trimmed.take(20) + "...",
|
||||
),
|
||||
)
|
||||
}
|
||||
is NProfile -> {
|
||||
results.add(
|
||||
SearchResult.UserResult(
|
||||
pubKeyHex = parsed.hex,
|
||||
displayId = trimmed.take(20) + "...",
|
||||
),
|
||||
)
|
||||
}
|
||||
is NSec -> {
|
||||
results.add(
|
||||
SearchResult.UserResult(
|
||||
pubKeyHex = parsed.toPubKeyHex(),
|
||||
displayId = "User from nsec",
|
||||
),
|
||||
)
|
||||
}
|
||||
is NNote -> {
|
||||
results.add(
|
||||
SearchResult.NoteResult(
|
||||
noteIdHex = parsed.hex,
|
||||
displayId = trimmed.take(20) + "...",
|
||||
),
|
||||
)
|
||||
}
|
||||
is NEvent -> {
|
||||
results.add(
|
||||
SearchResult.NoteResult(
|
||||
noteIdHex = parsed.hex,
|
||||
displayId = trimmed.take(20) + "...",
|
||||
),
|
||||
)
|
||||
}
|
||||
is NAddress -> {
|
||||
results.add(
|
||||
SearchResult.AddressResult(
|
||||
kind = parsed.kind,
|
||||
pubKeyHex = parsed.author,
|
||||
dTag = parsed.dTag,
|
||||
displayId = trimmed.take(20) + "...",
|
||||
),
|
||||
)
|
||||
}
|
||||
else -> { }
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// Try to parse as hex (64-char pubkey or event id)
|
||||
if (trimmed.length == 64 && Hex.isHex(trimmed)) {
|
||||
results.add(
|
||||
SearchResult.UserResult(
|
||||
pubKeyHex = trimmed,
|
||||
displayId = trimmed.take(16) + "..." + trimmed.takeLast(8),
|
||||
),
|
||||
)
|
||||
results.add(
|
||||
SearchResult.NoteResult(
|
||||
noteIdHex = trimmed,
|
||||
displayId = trimmed.take(16) + "..." + trimmed.takeLast(8),
|
||||
),
|
||||
)
|
||||
return results
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.search
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
|
||||
/**
|
||||
* Represents a parsed search result from Bech32/hex input.
|
||||
* Shared between Android and Desktop for consistent search behavior.
|
||||
*/
|
||||
sealed class SearchResult {
|
||||
/**
|
||||
* Direct user lookup from npub, nprofile, nsec, or hex pubkey.
|
||||
*/
|
||||
data class UserResult(
|
||||
val pubKeyHex: String,
|
||||
val displayId: String,
|
||||
) : SearchResult()
|
||||
|
||||
/**
|
||||
* User from local cache with full metadata.
|
||||
*/
|
||||
data class CachedUserResult(
|
||||
val user: User,
|
||||
) : SearchResult()
|
||||
|
||||
/**
|
||||
* Note lookup from note1 or nevent.
|
||||
*/
|
||||
data class NoteResult(
|
||||
val noteIdHex: String,
|
||||
val displayId: String,
|
||||
) : SearchResult()
|
||||
|
||||
/**
|
||||
* Addressable event lookup from naddr.
|
||||
*/
|
||||
data class AddressResult(
|
||||
val kind: Int,
|
||||
val pubKeyHex: String,
|
||||
val dTag: String,
|
||||
val displayId: String,
|
||||
) : SearchResult()
|
||||
|
||||
/**
|
||||
* Hashtag search.
|
||||
*/
|
||||
data class HashtagResult(
|
||||
val hashtag: String,
|
||||
) : SearchResult()
|
||||
}
|
||||
+3
-2
@@ -90,6 +90,7 @@ class EventCollectionState<T : Any>(
|
||||
mutex.withLock {
|
||||
val itemId = getId(item)
|
||||
if (itemId !in seenIds) {
|
||||
seenIds.add(itemId)
|
||||
pendingItems.add(item)
|
||||
scheduleBatchUpdate()
|
||||
}
|
||||
@@ -108,6 +109,7 @@ class EventCollectionState<T : Any>(
|
||||
mutex.withLock {
|
||||
val newItems = items.filter { getId(it) !in seenIds }
|
||||
if (newItems.isNotEmpty()) {
|
||||
newItems.forEach { seenIds.add(getId(it)) }
|
||||
pendingItems.addAll(newItems)
|
||||
scheduleBatchUpdate()
|
||||
}
|
||||
@@ -191,8 +193,7 @@ class EventCollectionState<T : Any>(
|
||||
mutex.withLock {
|
||||
if (pendingItems.isEmpty()) return
|
||||
|
||||
// Add pending IDs to seenIds
|
||||
pendingItems.forEach { seenIds.add(getId(it)) }
|
||||
// seenIds already updated in addItem/addItems
|
||||
|
||||
// Merge with existing items
|
||||
val merged = _items.value + pendingItems
|
||||
|
||||
+10
-6
@@ -34,8 +34,12 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.SharedRes
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.action_refresh
|
||||
import com.vitorpamplona.amethyst.commons.resources.action_try_again
|
||||
import com.vitorpamplona.amethyst.commons.resources.error_loading_feed
|
||||
import com.vitorpamplona.amethyst.commons.resources.feed_empty
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
/**
|
||||
* A centered loading state with a progress indicator and message.
|
||||
@@ -71,7 +75,7 @@ fun EmptyState(
|
||||
onRefresh: (() -> Unit)? = null,
|
||||
refreshLabel: String? = null,
|
||||
) {
|
||||
val actualRefreshLabel = refreshLabel ?: stringResource(SharedRes.strings.action_refresh)
|
||||
val actualRefreshLabel = refreshLabel ?: stringResource(Res.string.action_refresh)
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
@@ -110,7 +114,7 @@ fun ErrorState(
|
||||
onRetry: (() -> Unit)? = null,
|
||||
retryLabel: String? = null,
|
||||
) {
|
||||
val actualRetryLabel = retryLabel ?: stringResource(SharedRes.strings.action_try_again)
|
||||
val actualRetryLabel = retryLabel ?: stringResource(Res.string.action_try_again)
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
@@ -140,7 +144,7 @@ fun FeedEmptyState(
|
||||
title: String? = null,
|
||||
onRefresh: (() -> Unit)? = null,
|
||||
) {
|
||||
val actualTitle = title ?: stringResource(SharedRes.strings.feed_empty)
|
||||
val actualTitle = title ?: stringResource(Res.string.feed_empty)
|
||||
|
||||
EmptyState(
|
||||
title = actualTitle,
|
||||
@@ -158,7 +162,7 @@ fun FeedErrorState(
|
||||
modifier: Modifier = Modifier,
|
||||
onRetry: (() -> Unit)? = null,
|
||||
) {
|
||||
val formattedMessage = stringResource(SharedRes.strings.error_loading_feed).format(errorMessage)
|
||||
val formattedMessage = stringResource(Res.string.error_loading_feed).format(errorMessage)
|
||||
|
||||
ErrorState(
|
||||
message = formattedMessage,
|
||||
|
||||
+5
-2
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.commons.ui.components
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Face
|
||||
@@ -62,8 +63,10 @@ fun UserAvatar(
|
||||
loadRobohash: Boolean = true,
|
||||
) {
|
||||
val avatarModifier =
|
||||
remember(size) {
|
||||
modifier.clip(shape = CircleShape)
|
||||
remember(size, modifier) {
|
||||
modifier
|
||||
.size(size)
|
||||
.clip(shape = CircleShape)
|
||||
}
|
||||
|
||||
if (pictureUrl != null && loadProfilePicture) {
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
|
||||
/**
|
||||
* A card displaying user search result with avatar, name, and nip05/pubkey.
|
||||
* Shared between Android and Desktop search screens.
|
||||
*/
|
||||
@Composable
|
||||
fun UserSearchCard(
|
||||
user: User,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp).fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
UserAvatar(
|
||||
userHex = user.pubkeyHex,
|
||||
pictureUrl = user.profilePicture(),
|
||||
size = 40.dp,
|
||||
contentDescription = "Profile picture",
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
user.toBestDisplayName(),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
val nip05 = user.nip05()
|
||||
if (nip05 != null) {
|
||||
Text(
|
||||
nip05,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
user.pubkeyDisplayHex(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowForward,
|
||||
contentDescription = "Navigate",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.ui.notifications
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.LoadedFeedState
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
/**
|
||||
* Base interface for notification cards.
|
||||
*
|
||||
* Cards represent grouped or individual notification items in the feed.
|
||||
* Platform implementations provide concrete card types (NoteCard, MultiSetCard, etc.).
|
||||
*/
|
||||
@Immutable
|
||||
interface Card {
|
||||
/**
|
||||
* Returns the creation timestamp of this card.
|
||||
* Used for sorting cards in chronological order.
|
||||
*/
|
||||
fun createdAt(): Long
|
||||
|
||||
/**
|
||||
* Returns a unique identifier for this card.
|
||||
* Used for list diffing and deduplication.
|
||||
*/
|
||||
fun id(): String
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed state for notification cards.
|
||||
*
|
||||
* Mirrors FeedState but specifically typed for Card content.
|
||||
*/
|
||||
@Stable
|
||||
sealed class CardFeedState {
|
||||
@Immutable
|
||||
object Loading : CardFeedState()
|
||||
|
||||
@Stable
|
||||
class Loaded(
|
||||
val feed: MutableStateFlow<LoadedFeedState<Card>>,
|
||||
) : CardFeedState()
|
||||
|
||||
@Immutable
|
||||
object Empty : CardFeedState()
|
||||
|
||||
@Immutable
|
||||
class FeedError(
|
||||
val errorMessage: String,
|
||||
) : CardFeedState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Comparator for sorting cards by creation time (newest first).
|
||||
*/
|
||||
val DefaultCardComparator: Comparator<Card> =
|
||||
compareByDescending<Card> { it.createdAt() }
|
||||
.thenByDescending { it.id() }
|
||||
+14
-8
@@ -28,8 +28,14 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.SharedRes
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.screen_messages_description
|
||||
import com.vitorpamplona.amethyst.commons.resources.screen_messages_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.screen_notifications_description
|
||||
import com.vitorpamplona.amethyst.commons.resources.screen_notifications_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.screen_search_description
|
||||
import com.vitorpamplona.amethyst.commons.resources.screen_search_title
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
/**
|
||||
* Generic placeholder screen with title and description.
|
||||
@@ -60,8 +66,8 @@ fun PlaceholderScreen(
|
||||
@Composable
|
||||
fun SearchPlaceholder(modifier: Modifier = Modifier) {
|
||||
PlaceholderScreen(
|
||||
title = stringResource(SharedRes.strings.screen_search_title),
|
||||
description = stringResource(SharedRes.strings.screen_search_description),
|
||||
title = stringResource(Res.string.screen_search_title),
|
||||
description = stringResource(Res.string.screen_search_description),
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -72,8 +78,8 @@ fun SearchPlaceholder(modifier: Modifier = Modifier) {
|
||||
@Composable
|
||||
fun MessagesPlaceholder(modifier: Modifier = Modifier) {
|
||||
PlaceholderScreen(
|
||||
title = stringResource(SharedRes.strings.screen_messages_title),
|
||||
description = stringResource(SharedRes.strings.screen_messages_description),
|
||||
title = stringResource(Res.string.screen_messages_title),
|
||||
description = stringResource(Res.string.screen_messages_description),
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -84,8 +90,8 @@ fun MessagesPlaceholder(modifier: Modifier = Modifier) {
|
||||
@Composable
|
||||
fun NotificationsPlaceholder(modifier: Modifier = Modifier) {
|
||||
PlaceholderScreen(
|
||||
title = stringResource(SharedRes.strings.screen_notifications_title),
|
||||
description = stringResource(SharedRes.strings.screen_notifications_description),
|
||||
title = stringResource(Res.string.screen_notifications_title),
|
||||
description = stringResource(Res.string.screen_notifications_description),
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.viewmodels
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.amethyst.commons.search.SearchResult
|
||||
import com.vitorpamplona.amethyst.commons.search.parseSearchInput
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
|
||||
/**
|
||||
* State holder for search bar functionality.
|
||||
* Shared between Android and Desktop search screens.
|
||||
*
|
||||
* Handles:
|
||||
* - Search text input
|
||||
* - Bech32/hex parsing
|
||||
* - Local cache search (with debounce)
|
||||
* - Relay search results aggregation
|
||||
*
|
||||
* Platform-specific concerns (relay subscriptions, navigation) remain in the UI layer.
|
||||
*/
|
||||
class SearchBarState(
|
||||
private val cache: ICacheProvider,
|
||||
private val scope: CoroutineScope,
|
||||
private val debounceMs: Long = 300L,
|
||||
) {
|
||||
private val _searchText = MutableStateFlow("")
|
||||
val searchText: StateFlow<String> = _searchText.asStateFlow()
|
||||
|
||||
private val _bech32Results = MutableStateFlow<List<SearchResult>>(emptyList())
|
||||
val bech32Results: StateFlow<List<SearchResult>> = _bech32Results.asStateFlow()
|
||||
|
||||
private val _cachedUserResults = MutableStateFlow<List<User>>(emptyList())
|
||||
val cachedUserResults: StateFlow<List<User>> = _cachedUserResults.asStateFlow()
|
||||
|
||||
private val _relaySearchResults = MutableStateFlow<List<User>>(emptyList())
|
||||
val relaySearchResults: StateFlow<List<User>> = _relaySearchResults.asStateFlow()
|
||||
|
||||
private val _isSearchingRelays = MutableStateFlow(false)
|
||||
val isSearchingRelays: StateFlow<Boolean> = _isSearchingRelays.asStateFlow()
|
||||
|
||||
val hasResults: Boolean
|
||||
get() =
|
||||
_bech32Results.value.isNotEmpty() ||
|
||||
_cachedUserResults.value.isNotEmpty() ||
|
||||
_relaySearchResults.value.isNotEmpty()
|
||||
|
||||
val shouldSearchRelays: Boolean
|
||||
get() =
|
||||
_searchText.value.length >= 2 &&
|
||||
_bech32Results.value.isEmpty() &&
|
||||
_cachedUserResults.value.size < 5
|
||||
|
||||
init {
|
||||
setupSearchTextObserver()
|
||||
}
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
private fun setupSearchTextObserver() {
|
||||
// Debounced cache search
|
||||
_searchText
|
||||
.debounce(debounceMs)
|
||||
.onEach { query ->
|
||||
if (query.length >= 2 && _bech32Results.value.isEmpty()) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
_cachedUserResults.value = cache.findUsersStartingWith(query, 20) as List<User>
|
||||
} else {
|
||||
_cachedUserResults.value = emptyList()
|
||||
}
|
||||
}.launchIn(scope)
|
||||
}
|
||||
|
||||
fun updateSearchText(text: String) {
|
||||
_searchText.value = text
|
||||
_relaySearchResults.value = emptyList()
|
||||
_isSearchingRelays.value = false
|
||||
|
||||
// Parse Bech32/hex immediately (no debounce)
|
||||
_bech32Results.value = parseSearchInput(text)
|
||||
|
||||
// Clear cached results if query too short or is bech32
|
||||
if (text.length < 2 || _bech32Results.value.isNotEmpty()) {
|
||||
_cachedUserResults.value = emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
fun clearSearch() {
|
||||
updateSearchText("")
|
||||
}
|
||||
|
||||
fun startRelaySearch() {
|
||||
_isSearchingRelays.value = true
|
||||
}
|
||||
|
||||
fun endRelaySearch() {
|
||||
_isSearchingRelays.value = false
|
||||
}
|
||||
|
||||
fun addRelaySearchResult(user: User) {
|
||||
if (!_relaySearchResults.value.any { it.pubkeyHex == user.pubkeyHex }) {
|
||||
_relaySearchResults.value = _relaySearchResults.value + user
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user