Merge pull request #3424 from nrobi144/feat/desktop-follow-packs

feat(desktop): Follow Packs (NIP-51 kind 39089) — Discover + follow flow
This commit is contained in:
Vitor Pamplona
2026-06-30 08:44:40 -04:00
committed by GitHub
27 changed files with 2630 additions and 16 deletions
@@ -121,4 +121,30 @@ object FollowActions {
} else {
null
}
/**
* Batch-unfollow variant — removes every pubkey in [pubkeysToRemove] from
* the follow set in a single kind:3 update.
*
* Returns the original [currentContactList] if none of the pubkeys were
* present (no-op), or `null` when [currentContactList] is empty/null.
*/
suspend fun buildUnfollowBatch(
signer: NostrSigner,
pubkeysToRemove: Set<HexKey>,
currentContactList: ContactListEvent?,
): ContactListEvent? {
if (currentContactList == null || currentContactList.tags.isEmpty()) return null
if (pubkeysToRemove.isEmpty()) return currentContactList
val newTags =
currentContactList.tags
.filter { tag -> !(tag.size > 1 && tag[0] == "p" && tag[1] in pubkeysToRemove) }
.toTypedArray()
if (newTags.size == currentContactList.tags.size) return currentContactList
return ContactListEvent.create(
content = currentContactList.content,
tags = newTags,
signer = signer,
)
}
}
@@ -214,6 +214,7 @@ object MaterialSymbols {
val SettingsInputAntenna = MaterialSymbol("\uE8BF")
val Share = MaterialSymbol("\uE80D")
val Shield = MaterialSymbol("\uE9E0")
val Shuffle = MaterialSymbol("\uE043")
val SkipNext = MaterialSymbol("\uE044")
val SkipPrevious = MaterialSymbol("\uE045")
val SmartDisplay = MaterialSymbol("\uF06A")
@@ -42,6 +42,8 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class Kind3FollowListState(
val signer: NostrSigner,
@@ -113,27 +115,47 @@ class Kind3FollowListState(
)
}
// Serializes all kind:3 mutations so concurrent follow/unfollow/follow-all
// calls cannot lose writes. The read of getFollowListEvent() inside each
// call happens under the lock, so the dedupe baseline is always fresh.
private val followMutex = Mutex()
suspend fun follow(users: List<User>): ContactListEvent =
FollowActions.buildFollowBatch(
signer = signer,
pubkeysWithHints = users.map { it.pubkeyHex to it.bestRelayHint() },
currentContactList = getFollowListEvent(),
)
followMutex.withLock {
FollowActions.buildFollowBatch(
signer = signer,
pubkeysWithHints = users.map { it.pubkeyHex to it.bestRelayHint() },
currentContactList = getFollowListEvent(),
)
}
suspend fun follow(user: User): ContactListEvent =
FollowActions.buildFollow(
signer = signer,
pubkeyToFollow = user.pubkeyHex,
currentContactList = getFollowListEvent(),
relayHint = user.bestRelayHint(),
)
followMutex.withLock {
FollowActions.buildFollow(
signer = signer,
pubkeyToFollow = user.pubkeyHex,
currentContactList = getFollowListEvent(),
relayHint = user.bestRelayHint(),
)
}
suspend fun unfollow(user: User): ContactListEvent? =
FollowActions.buildUnfollow(
signer = signer,
pubkeyToUnfollow = user.pubkeyHex,
currentContactList = getFollowListEvent(),
)
followMutex.withLock {
FollowActions.buildUnfollow(
signer = signer,
pubkeyToUnfollow = user.pubkeyHex,
currentContactList = getFollowListEvent(),
)
}
suspend fun unfollow(users: List<User>): ContactListEvent? =
followMutex.withLock {
FollowActions.buildUnfollowBatch(
signer = signer,
pubkeysToRemove = users.mapTo(mutableSetOf()) { it.pubkeyHex },
currentContactList = getFollowListEvent(),
)
}
init {
settings.backupContactList?.let {
@@ -190,6 +190,12 @@ sealed class DesktopScreen {
data object Settings : DesktopScreen()
data object LocalRelaySettings : DesktopScreen()
data class FollowPackDetail(
val addressTag: String,
) : DesktopScreen()
data object FollowPackBrowseAll : DesktopScreen()
}
/** Reference to active Tor manager for shutdown hook. Set by App composable. */
@@ -1239,6 +1245,18 @@ fun MainContent(
DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope, accountRelays)
}
// Follow Packs state — single per-account holder for Discover + sidebar + naddr cards
val followPacksState =
remember(iAccount, localCache, relayManager, scope) {
com.vitorpamplona.amethyst.desktop.followpacks
.FollowPacksState(
cache = localCache,
relayManager = relayManager,
kind3FollowList = iAccount.kind3FollowList,
scope = scope,
)
}
// Aggregated relay categories (feed, notifications, search, DM)
val relayCategories =
remember(iAccount.nip65RelayList, accountRelays, relayManager) {
@@ -1497,6 +1515,7 @@ fun MainContent(
com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayManager provides relayManager,
com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayHealthStore provides relayHealthStore,
com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayListMutator provides relayListMutator,
com.vitorpamplona.amethyst.desktop.ui.deck.LocalFollowPacksState provides followPacksState,
) {
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize()) {
@@ -53,6 +53,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEve
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.utils.DualCase
@@ -272,6 +273,10 @@ class DesktopLocalCache : ICacheProvider {
consumeOldBookmarkList(event)
}
is FollowListEvent -> {
consumeFollowList(event)
}
is CommentEvent -> {
consumeComment(event, relay)
}
@@ -480,6 +485,14 @@ class DesktopLocalCache : ICacheProvider {
lastContactListCreatedAt = event.createdAt
lastContactListEvent = event
_followedUsers.value = event.verifiedFollowKeySet()
// Store in addressableNotes too — Kind3FollowListState.getFollowListEvent
// reads from getOrCreateAddressableNote(...) and would otherwise see a
// null event, causing buildFollowBatch to start a fresh kind-3 (wiping
// existing follows).
val addressableNote = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
addressableNote.loadEvent(event, author, emptyList())
return true
}
@@ -530,6 +543,40 @@ class DesktopLocalCache : ICacheProvider {
return true
}
/**
* Consumes a kind 39089 follow pack event (NIP-51).
* Addressable replaceable event — keyed by (kind, pubkey, dTag).
* Bumps [followPackVersion] so observers can refresh their snapshot.
*/
private val _followPackVersion = MutableStateFlow(0L)
val followPackVersion: StateFlow<Long> = _followPackVersion.asStateFlow()
private fun consumeFollowList(event: FollowListEvent): Boolean {
val address = event.address()
val addressableNote = getOrCreateAddressableNote(address)
val author = getOrCreateUser(event.pubKey)
val existingEvent = addressableNote.event
if (existingEvent != null && existingEvent.createdAt >= event.createdAt) return false
addressableNote.loadEvent(event, author, emptyList())
_followPackVersion.value++
return true
}
/**
* Snapshot all 39089 (FollowListEvent) addressable notes currently in cache.
* Use [followPackVersion] to drive reactive recomputation.
*/
fun snapshotFollowPacks(): List<FollowListEvent> {
val out = mutableListOf<FollowListEvent>()
addressableNotes.forEach { _, note ->
val ev = note.event
if (ev is FollowListEvent) out.add(ev)
}
return out
}
// ----- NWC Payment operations -----
/**
@@ -0,0 +1,125 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.followpacks
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
data class BulkFollowPreview(
val totalMembers: Int,
val newUsers: List<User>,
val existingUsers: List<User>,
) {
val newCount: Int get() = newUsers.size
val existingCount: Int get() = existingUsers.size
}
/**
* Stateless helpers for the Follow-all action.
*
* computePreview() resolves pack `p` tags to materialized Users
* (creating placeholders for unknown pubkeys so relay hints survive),
* and partitions them into new vs already-followed.
*
* commit() forwards to Kind3FollowListState.follow(users) which is
* Mutex-guarded against concurrent calls and dedupes inside Quartz.
*/
object BulkFollowAction {
/**
* Resolves every pubkey in [pack] to a User, including those without
* cached metadata (so the bulk-follow always carries relay hints).
*/
fun computePreview(
pack: FollowListEvent,
cache: DesktopLocalCache,
currentFollows: Set<HexKey>,
myPubkey: HexKey,
): BulkFollowPreview {
val newUsers = mutableListOf<User>()
val existingUsers = mutableListOf<User>()
pack.follows().forEach { userTag ->
// Skip self-follow attempts (gap N6)
if (userTag.pubKey == myPubkey) return@forEach
val user = cache.getOrCreateUser(userTag.pubKey)
if (userTag.pubKey in currentFollows) {
existingUsers.add(user)
} else {
newUsers.add(user)
}
}
return BulkFollowPreview(
totalMembers = pack.follows().size,
newUsers = newUsers,
existingUsers = existingUsers,
)
}
/**
* Publishes the bulk-follow. Returns the new ContactListEvent or null if
* publish/sign failed.
*/
suspend fun commit(
usersToAdd: List<User>,
iAccount: DesktopIAccount,
relayManager: RelayConnectionManager,
cache: DesktopLocalCache,
): Boolean {
if (!iAccount.isWriteable()) return false
if (usersToAdd.isEmpty()) return true
return try {
val newContactList = iAccount.kind3FollowList.follow(usersToAdd)
cache.consume(newContactList, null, wasVerified = false)
relayManager.broadcastToAll(newContactList)
true
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
false
}
}
/** Unfollows every pack member that the user currently follows. */
suspend fun commitUnfollowAll(
pack: FollowListEvent,
iAccount: DesktopIAccount,
relayManager: RelayConnectionManager,
cache: DesktopLocalCache,
): Boolean {
if (!iAccount.isWriteable()) return false
val toRemove =
pack.followIds().mapNotNull { hex ->
if (hex != iAccount.pubKey) cache.getOrCreateUser(hex) else null
}
if (toRemove.isEmpty()) return true
return try {
val newContactList = iAccount.kind3FollowList.unfollow(toRemove) ?: return false
cache.consume(newContactList, null, wasVerified = false)
relayManager.broadcastToAll(newContactList)
true
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
false
}
}
}
@@ -0,0 +1,48 @@
/*
* 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.desktop.followpacks
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
/**
* Lightweight helpers for follow-pack viewing actions.
*
* v1 scope: view + follow + share. No create/edit/delete.
*/
object FollowPackEditor {
/** Encodes the pack as a `nostr:naddr1…` URI for sharing via clipboard. */
fun toShareUri(pack: FollowListEvent): String {
val naddr =
com.vitorpamplona.quartz.nip19Bech32.entities
.NAddress
.create(
kind = FollowListEvent.KIND,
pubKeyHex = pack.pubKey,
dTag = pack.dTag(),
relays = emptyList(),
)
return "nostr:$naddr"
}
/** Address coordinate (e.g. "39089:<pubkey>:<dTag>") — stable identity. */
fun aTag(pack: FollowListEvent): String = Address.assemble(FollowListEvent.KIND, pack.pubKey, pack.dTag())
}
@@ -0,0 +1,199 @@
/*
* 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.desktop.followpacks
import com.vitorpamplona.amethyst.commons.model.nip02FollowList.Kind3FollowListState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
/**
* Single state holder for everything follow-pack-related on Desktop.
*
* Sources:
* - Featured pack: random pick from cached 39089 events (refreshed by Shuffle).
* - Followed-author packs: 39089 events authored by accounts the user follows.
* - All packs: every 39089 in the cache (used by Discover gallery / Browse-all).
*
* Subscribes to a multi-relay set for global pack discovery.
* (relay.following.space doesn't actually exist — using popular indexer relays.)
*/
class FollowPacksState(
private val cache: DesktopLocalCache,
private val relayManager: RelayConnectionManager,
private val kind3FollowList: Kind3FollowListState,
private val scope: CoroutineScope,
) {
private val followedAuthors: StateFlow<Set<HexKey>> =
kind3FollowList.flow
.map { it.authors }
.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = kind3FollowList.flow.value.authors,
)
companion object {
private const val SUB_ID = "discover-follow-packs"
// Multi-relay set for global pack discovery.
// following.space (mentioned in plan) does NOT have its own relay —
// it uses these popular relays. Verified empirically.
val DISCOVERY_RELAYS: Set<NormalizedRelayUrl> =
listOfNotNull(
RelayUrlNormalizer.normalizeOrNull("wss://relay.damus.io"),
RelayUrlNormalizer.normalizeOrNull("wss://relay.nostr.band"),
RelayUrlNormalizer.normalizeOrNull("wss://relay.primal.net"),
RelayUrlNormalizer.normalizeOrNull("wss://nos.lol"),
).toSet()
}
/** All 39089 events currently in the cache. Recomputes on cache change. */
val allPacks: StateFlow<List<FollowListEvent>> =
cache.followPackVersion
.map { cache.snapshotFollowPacks().sortedByDescending { it.createdAt } }
.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = cache.snapshotFollowPacks().sortedByDescending { it.createdAt },
)
/** Packs authored by accounts the user follows. Top-3 for sidebar. */
val followedAuthorsPacks: StateFlow<List<FollowListEvent>> =
combine(allPacks, followedAuthors) { packs, followed ->
packs.filter { it.pubKey in followed }
}.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = emptyList(),
)
/**
* Sidebar top-3: own packs + packs by people user follows.
* Falls back to most-recent-from-relays when user has no follow-derived packs.
*/
val sidebarTopPacks: StateFlow<List<FollowListEvent>> =
followedAuthorsPacks
.map { followedPacks ->
if (followedPacks.isNotEmpty()) {
followedPacks.take(3)
} else {
// empty-state fallback: most recent from cache (any author)
allPacks.value.take(3)
}
}.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = emptyList(),
)
// ---- Featured pack with shuffle ----
private val shuffleTickInternal = MutableStateFlow(0L)
/** Increments on every shuffle() call. Used by gallery to re-randomize order. */
val shuffleTick: StateFlow<Long> = shuffleTickInternal.asStateFlow()
private val recentlyShown = ArrayDeque<String>(5)
private val featuredPackInternal = MutableStateFlow<FollowListEvent?>(null)
val featuredPack: StateFlow<FollowListEvent?> = featuredPackInternal.asStateFlow()
init {
// Recompute featured on cache or shuffle change.
scope.launch(Dispatchers.Default) {
combine(allPacks, shuffleTickInternal) { packs, _ -> packs }.collect { packs ->
featuredPackInternal.value = pickRandom(packs)
}
}
// Subscribe to discovery relays for global pack feed.
subscribeToDiscovery()
}
fun shuffle() {
shuffleTickInternal.value++
}
private fun pickRandom(packs: List<FollowListEvent>): FollowListEvent? {
if (packs.isEmpty()) return null
val candidates = packs.filter { it.address().toValue() !in recentlyShown }
val pool = if (candidates.isEmpty()) packs else candidates
val pick = pool.random()
rememberShown(pick.address().toValue())
return pick
}
private fun rememberShown(aTag: String) {
if (recentlyShown.size >= 5) recentlyShown.removeFirst()
recentlyShown.addLast(aTag)
}
private fun subscribeToDiscovery() {
val listener =
object : SubscriptionListener {
override fun onEvent(
event: com.vitorpamplona.quartz.nip01Core.core.Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
cache.consume(event, relay)
}
}
// For each discovery relay, ensure it's added then subscribe.
scope.launch {
// small grace period for relay manager to settle
delay(500)
DISCOVERY_RELAYS.forEach { relay ->
relayManager.subscribeOnRelay(
relay = relay,
subId = "$SUB_ID-${relay.url.hashCode()}",
filters = listOf(Filter(kinds = listOf(FollowListEvent.KIND), limit = 200)),
onEvent = { event, r -> cache.consume(event, r) },
)
// Suppress unused variable warning
@Suppress("UNUSED_VARIABLE")
val unused = listener
}
}
}
/** Returns hashtags present on this pack, lowercase + deduped. */
fun hashtagsFor(pack: FollowListEvent): List<String> = pack.hashtags().map { it.lowercase() }.distinct()
}
@@ -0,0 +1,59 @@
/*
* 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.desktop.followpacks
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
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.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* Subscribes for kind:0 metadata events for the given pubkeys across the
* default relays, forwarding incoming events to the cache.
*
* Returns a closer lambda — caller is responsible for unsubscribing via
* DisposableEffect's onDispose.
*/
fun RelayConnectionManager.subscribeMetadataFor(
pubkeys: List<HexKey>,
cache: DesktopLocalCache,
): () -> Unit {
if (pubkeys.isEmpty()) return { }
val subId = "pack-members-meta-${pubkeys.hashCode()}"
val filter = Filter(kinds = listOf(MetadataEvent.KIND), authors = pubkeys)
val listener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
cache.consume(event, relay)
}
}
subscribe(subId, listOf(filter), listener = listener)
return { unsubscribe(subId) }
}
@@ -0,0 +1,352 @@
/*
* 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.desktop.followpacks.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.followpacks.FollowPackEditor
import com.vitorpamplona.amethyst.desktop.followpacks.FollowPacksState
import com.vitorpamplona.amethyst.desktop.followpacks.subscribeMetadataFor
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import kotlinx.coroutines.launch
import java.awt.Toolkit
import java.awt.datatransfer.StringSelection
@Composable
fun DiscoverScreen(
state: FollowPacksState,
cache: DesktopLocalCache,
iAccount: DesktopIAccount,
relayManager: RelayConnectionManager,
onOpenPack: (String) -> Unit,
onOpenBrowseAll: () -> Unit,
onNavigateToProfile: (String) -> Unit,
onNavigateToThread: (String) -> Unit,
@Suppress("UNUSED_PARAMETER") onZapFeedback: (ZapFeedback) -> Unit,
modifier: Modifier = Modifier,
) {
val featured by state.featuredPack.collectAsState()
val allPacks by state.allPacks.collectAsState()
var selectedChip by remember { mutableStateOf<String?>(null) }
var showFollowAll by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val current = featured
val followedAuthors by iAccount.kind3FollowList.flow.collectAsState()
val shuffleTick by state.shuffleTick.collectAsState()
val fullyFollowed =
remember(current, followedAuthors) {
current != null && current.followIds().isNotEmpty() &&
current.followIds().all { it == iAccount.pubKey || it in followedAuthors.authors }
}
val galleryPacks =
remember(allPacks, selectedChip, current, shuffleTick) {
val others = allPacks.filter { it.id != current?.id }
val filtered =
if (selectedChip == null) {
others
} else {
others.filter { pack ->
pack.hashtags().any { it.equals(selectedChip, ignoreCase = true) }
}
}
// Reshuffle order on every shuffle tick so the right rail rotates too.
if (shuffleTick > 0) filtered.shuffled() else filtered
}
// Prefetch member metadata for the featured pack so avatars + names show up.
DisposableEffect(current?.id) {
val pack = current
if (pack != null) {
val handle = relayManager.subscribeMetadataFor(pack.followIds().take(50), cache)
onDispose { handle() }
} else {
onDispose { }
}
}
val onShareCurrent: () -> Unit =
remember(current) {
{
current?.let {
copyToClipboardLocal(FollowPackEditor.toShareUri(it))
scope.launch {
snackbarHostState.showSnackbar("Pack link copied to clipboard")
}
}
}
}
Box(modifier = modifier.fillMaxSize()) {
Column(
modifier =
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
// ---- Top section: Row(Hero | RightRail) ----
if (current == null) {
EmptyDiscoverHero(onOpenBrowseAll = onOpenBrowseAll)
} else {
Row(modifier = Modifier.fillMaxWidth()) {
Box(modifier = Modifier.weight(2f)) {
FollowPackHeroCard(
pack = current,
cache = cache,
onFollowAll = { showFollowAll = true },
onShare = onShareCurrent,
onOpenDetail = { onOpenPack(FollowPackEditor.aTag(current)) },
fullyFollowed = fullyFollowed,
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(Modifier.width(12.dp))
Column(
modifier = Modifier.width(220.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
galleryPacks.take(4).forEach { pack ->
MiniPackThumb(
pack = pack,
onClick = { onOpenPack(FollowPackEditor.aTag(pack)) },
)
}
if (galleryPacks.size > 4) {
Text(
text = "Browse all ${galleryPacks.size}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier =
Modifier
.padding(top = 4.dp)
.clickable(onClick = onOpenBrowseAll),
)
}
}
}
}
// ---- Topic chips ----
val chipTags = remember(current) { current?.let { state.hashtagsFor(it) } ?: emptyList() }
if (chipTags.isNotEmpty()) {
Spacer(Modifier.height(16.dp))
Text(
text = "TOPICS",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(6.dp))
FollowPackChipRail(
tags = chipTags,
selected = selectedChip,
onSelect = { selectedChip = it },
)
}
// ---- From the pack ----
if (current != null && current.followIds().isNotEmpty()) {
Spacer(Modifier.height(24.dp))
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
text = "From the pack",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
TextButton(onClick = { state.shuffle() }) {
Icon(
symbol = MaterialSymbols.Shuffle,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(6.dp))
Text("Shuffle")
}
}
Spacer(Modifier.height(8.dp))
FromThePackFeed(
memberHexes = current.followIdSet(),
cache = cache,
relayManager = relayManager,
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
)
}
}
// Snackbar host overlay at bottom — confirms Share copy etc.
SnackbarHost(
hostState = snackbarHostState,
modifier =
Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 16.dp),
) { data ->
Snackbar(snackbarData = data)
}
}
if (showFollowAll && current != null) {
FollowAllConfirmDialog(
pack = current,
iAccount = iAccount,
cache = cache,
relayManager = relayManager,
onDismiss = { showFollowAll = false },
)
}
}
@Composable
private fun MiniPackThumb(
pack: FollowListEvent,
onClick: () -> Unit,
) {
val title = pack.title()?.ifBlank { null } ?: "Untitled pack"
val image = pack.image()?.ifBlank { null }
Box(
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 72.dp)
.clip(RoundedCornerShape(12.dp))
.background(
Color(
(title.hashCode() and 0x80FFFFFF.toInt()) or 0xFF608080.toInt(),
),
).clickable(onClick = onClick),
) {
if (image != null) {
AsyncImage(
model = image,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().height(72.dp),
)
} else {
Box(modifier = Modifier.fillMaxWidth().height(72.dp))
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.align(Alignment.BottomStart)
.padding(8.dp)
.clip(MaterialTheme.shapes.small)
.background(color = MaterialTheme.colorScheme.surface)
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Icon(
symbol = MaterialSymbols.Add,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.width(4.dp))
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun EmptyDiscoverHero(onOpenBrowseAll: () -> Unit) {
Column(
modifier = Modifier.fillMaxWidth().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "Discover follow packs",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.height(8.dp))
Text(
text = "Curated lists of people you can follow in one tap.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
Button(onClick = onOpenBrowseAll) { Text("Browse packs") }
}
}
internal fun copyToClipboardLocal(text: String) {
runCatching {
val clipboard = Toolkit.getDefaultToolkit().systemClipboard
clipboard.setContents(StringSelection(text), null)
}
}
@@ -0,0 +1,156 @@
/*
* 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.desktop.followpacks.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.followpacks.BulkFollowAction
import com.vitorpamplona.amethyst.desktop.followpacks.BulkFollowPreview
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* Confirm dialog for bulk follow / unfollow. Detects current state and switches
* the primary action between "Follow N" and "Unfollow M" automatically.
*
* Cancel is the safer default.
*/
@Composable
fun FollowAllConfirmDialog(
pack: FollowListEvent,
iAccount: DesktopIAccount,
cache: DesktopLocalCache,
relayManager: RelayConnectionManager,
onDismiss: () -> Unit,
onSuccess: () -> Unit = {},
onFailure: () -> Unit = {},
) {
val scope = rememberCoroutineScope()
var publishing by remember { mutableStateOf(false) }
var preview by remember { mutableStateOf<BulkFollowPreview?>(null) }
// Recompute on every kind-3 change so the dialog reflects current state
val current by iAccount.kind3FollowList.flow.collectAsState()
LaunchedEffect(pack.id, current.authors) {
preview =
BulkFollowAction.computePreview(pack, cache, current.authors, iAccount.pubKey)
}
val p = preview ?: return
val isFullyFollowed = p.newCount == 0 && p.existingCount > 0
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = if (isFullyFollowed) "Unfollow this pack?" else "Follow this pack?",
style = MaterialTheme.typography.titleMedium,
)
},
text = {
Column {
Text(
text = pack.title()?.ifBlank { null } ?: "Untitled pack",
style = MaterialTheme.typography.titleSmall,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text =
when {
isFullyFollowed ->
"Unfollow all ${p.existingCount} ${if (p.existingCount == 1) "person" else "people"} in this pack?"
p.newCount == 0 -> "Nothing to do."
else ->
"Follow ${p.newCount} new ${if (p.newCount == 1) "person" else "people"}" +
if (p.existingCount > 0) " (${p.existingCount} already followed)." else "."
},
style = MaterialTheme.typography.bodyMedium,
)
}
},
confirmButton = {
TextButton(
enabled = !publishing && (p.newCount > 0 || isFullyFollowed),
onClick = {
publishing = true
scope.launch {
val freshCurrent = iAccount.kind3FollowList.flow.value.authors
val refreshed =
BulkFollowAction.computePreview(pack, cache, freshCurrent, iAccount.pubKey)
val fullyFollowedNow = refreshed.newCount == 0 && refreshed.existingCount > 0
val ok =
if (fullyFollowedNow) {
BulkFollowAction.commitUnfollowAll(
pack = pack,
iAccount = iAccount,
relayManager = relayManager,
cache = cache,
)
} else {
BulkFollowAction.commit(
usersToAdd = refreshed.newUsers,
iAccount = iAccount,
relayManager = relayManager,
cache = cache,
)
}
delay(400)
publishing = false
if (ok) onSuccess() else onFailure()
onDismiss()
}
},
) {
Text(
when {
publishing -> "Publishing…"
isFullyFollowed -> "Unfollow ${p.existingCount}"
else -> "Follow ${p.newCount}"
},
)
}
},
dismissButton = {
TextButton(onClick = onDismiss, enabled = !publishing) {
Text("Cancel")
}
},
)
}
@@ -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.desktop.followpacks.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.followpacks.FollowPackEditor
import com.vitorpamplona.amethyst.desktop.followpacks.FollowPacksState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
/**
* Search-all view for follow packs.
* Search matches across: title, description, creator name/npub, `t` tag.
*/
@Composable
fun FollowPackBrowseAllScreen(
state: FollowPacksState,
cache: DesktopLocalCache,
onOpenPack: (String) -> Unit,
onBack: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
var query by remember { mutableStateOf("") }
val packs by state.allPacks.collectAsState()
@Suppress("UNUSED_VARIABLE")
val metadataVersion by cache.metadataVersion.collectAsState()
val results =
remember(packs, query, metadataVersion) {
if (query.isBlank()) packs else filter(packs, query.trim(), cache)
}
Column(modifier = modifier.fillMaxSize().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
if (onBack != null) {
IconButton(onClick = onBack, modifier = Modifier.size(36.dp)) {
Icon(
MaterialSymbols.AutoMirrored.ArrowBack,
contentDescription = "Back",
modifier = Modifier.size(20.dp),
)
}
Spacer(Modifier.padding(4.dp))
}
OutlinedTextField(
value = query,
onValueChange = { query = it },
placeholder = { Text("Search packs by title, creator, or tag…") },
singleLine = true,
modifier = Modifier.weight(1f),
)
}
Spacer(Modifier.height(12.dp))
Text(
text = "${results.size} ${if (results.size == 1) "pack" else "packs"}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
if (results.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text =
if (query.isBlank()) {
"No packs in cache yet. They'll appear as relays send them."
} else {
"No packs match \"$query\""
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
LazyColumn(
contentPadding = PaddingValues(vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(items = results, key = { FollowPackEditor.aTag(it) }) { pack ->
FollowPackRow(
pack = pack,
cache = cache,
onClick = { onOpenPack(FollowPackEditor.aTag(pack)) },
)
}
}
}
}
}
private fun filter(
packs: List<FollowListEvent>,
raw: String,
cache: DesktopLocalCache,
): List<FollowListEvent> {
val q = raw.lowercase()
val asHex: HexKey? = decodePublicKeyAsHexOrNull(raw)
return packs.filter { pack ->
val title = pack.title()?.lowercase().orEmpty()
val desc = pack.description()?.lowercase().orEmpty()
val tags = pack.hashtags().map { it.lowercase() }
val creator = cache.getUserIfExists(pack.pubKey)
val creatorName = creator?.toBestDisplayName()?.lowercase().orEmpty()
val creatorNpub = creator?.pubkeyNpub()?.lowercase().orEmpty()
title.contains(q) ||
desc.contains(q) ||
tags.any { it.contains(q) } ||
creatorName.contains(q) ||
(q.length >= 4 && creatorNpub.startsWith(q)) ||
(asHex != null && pack.pubKey.equals(asHex, ignoreCase = true))
}
}
@@ -0,0 +1,361 @@
/*
* 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.desktop.followpacks.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
/**
* Stack of overlapping circular avatars for pack members.
* Shows up to [maxVisible] avatars; remainder is "+N" badge.
*/
@Composable
fun FollowPackAvatarStack(
memberHexes: List<HexKey>,
cache: DesktopLocalCache,
modifier: Modifier = Modifier,
avatarSize: androidx.compose.ui.unit.Dp = 32.dp,
maxVisible: Int = 5,
) {
@Suppress("UNUSED_VARIABLE")
val metadataVersion by cache.metadataVersion.collectAsState()
val visible = remember(memberHexes, maxVisible) { memberHexes.take(maxVisible) }
val remaining = (memberHexes.size - visible.size).coerceAtLeast(0)
val overlap = (avatarSize.value * 0.35f).dp
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
visible.forEachIndexed { index, hex ->
val pictureUrl = remember(hex, metadataVersion) { cache.getUserIfExists(hex)?.profilePicture() }
Box(
modifier =
Modifier
.offset(x = if (index == 0) 0.dp else (-overlap) * index)
.size(avatarSize + 4.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
UserAvatar(
userHex = hex,
pictureUrl = pictureUrl,
size = avatarSize,
contentDescription = "Pack member",
)
}
}
if (remaining > 0) {
Box(
modifier =
Modifier
.offset(x = (-overlap) * visible.size)
.size(avatarSize + 4.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
Box(
modifier =
Modifier
.size(avatarSize)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
Text(
text = "+$remaining",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.SemiBold,
)
}
}
}
}
}
/**
* Big hero card for the featured pack. Title + description + avatar stack + Follow-all CTA.
*
* [fullyFollowed]: when true, the action button flips to "Unfollow all".
*/
@Composable
fun FollowPackHeroCard(
pack: FollowListEvent,
cache: DesktopLocalCache,
onFollowAll: () -> Unit,
onShare: () -> Unit,
onOpenDetail: () -> Unit,
fullyFollowed: Boolean = false,
modifier: Modifier = Modifier,
) {
val title = pack.title()?.ifBlank { null } ?: "Untitled pack"
val description = pack.description()?.ifBlank { null }
val image = pack.image()?.ifBlank { null }
val memberCount = pack.follows().size
Card(
modifier = modifier.fillMaxWidth().clickable(onClick = onOpenDetail),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
shape = RoundedCornerShape(16.dp),
) {
// Image header (AsyncImage when available, gradient fallback)
Box(
modifier =
Modifier
.fillMaxWidth()
.height(140.dp)
.clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp))
.background(
Color(
(title.hashCode() and 0x80FFFFFF.toInt()) or 0xFF608080.toInt(),
),
),
) {
if (image != null) {
AsyncImage(
model = image,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().height(140.dp),
)
}
}
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
if (description != null) {
Spacer(Modifier.height(6.dp))
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.85f),
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.height(12.dp))
FollowPackAvatarStack(
memberHexes = pack.followIds(),
cache = cache,
avatarSize = 32.dp,
)
Spacer(Modifier.height(4.dp))
Text(
text = "$memberCount ${if (memberCount == 1) "member" else "members"}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
)
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (fullyFollowed) {
OutlinedButton(onClick = onFollowAll) {
Icon(
symbol = MaterialSymbols.PersonAdd,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(6.dp))
Text("Unfollow all")
}
} else {
Button(onClick = onFollowAll) {
Icon(
symbol = MaterialSymbols.PersonAdd,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(6.dp))
Text("Follow all")
}
}
OutlinedButton(onClick = onShare) {
Icon(
symbol = MaterialSymbols.ContentCopy,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(6.dp))
Text("Share")
}
}
}
}
}
/**
* Horizontal chip rail for hashtags pulled from a featured pack.
* Click toggles a filter on the gallery below.
*/
@Composable
fun FollowPackChipRail(
tags: List<String>,
selected: String?,
onSelect: (String?) -> Unit,
modifier: Modifier = Modifier,
) {
if (tags.isEmpty()) return
LazyRow(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
contentPadding =
androidx.compose.foundation.layout
.PaddingValues(horizontal = 4.dp),
) {
items(tags) { tag ->
FilterChip(
selected = tag == selected,
onClick = { onSelect(if (tag == selected) null else tag) },
label = { Text("#$tag") },
)
}
}
}
/**
* Single thumbnail row used in galleries and browse-all lists.
*/
@Composable
fun FollowPackRow(
pack: FollowListEvent,
cache: DesktopLocalCache,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val title = pack.title()?.ifBlank { null } ?: "Untitled pack"
val description = pack.description()?.ifBlank { null }
val image = pack.image()?.ifBlank { null }
val memberCount = pack.follows().size
Card(
modifier = modifier.fillMaxWidth().clickable(onClick = onClick),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
shape = RoundedCornerShape(12.dp),
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.Top,
) {
Box(
modifier =
Modifier
.size(56.dp)
.clip(RoundedCornerShape(8.dp))
.background(
Color(
(title.hashCode() and 0x80FFFFFF.toInt()) or 0xFF608080.toInt(),
),
),
) {
if (image != null) {
AsyncImage(
model = image,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(56.dp),
)
}
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (description != null) {
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.height(6.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
FollowPackAvatarStack(
memberHexes = pack.followIds(),
cache = cache,
avatarSize = 20.dp,
maxVisible = 4,
)
Spacer(Modifier.width(8.dp))
Text(
text = "$memberCount ${if (memberCount == 1) "member" else "members"}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@@ -0,0 +1,396 @@
/*
* 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.desktop.followpacks.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.followpacks.FollowPackEditor
import com.vitorpamplona.amethyst.desktop.followpacks.FollowPacksState
import com.vitorpamplona.amethyst.desktop.followpacks.subscribeMetadataFor
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import kotlinx.coroutines.launch
/**
* Read-only pack detail screen. Opened as overlay when user clicks a pack
* in Discover or Browse-all.
*
* Shows: pack hero (title, desc, image, member count), Follow all + Share +
* Open profile actions, scrollable member list.
*/
@Composable
fun FollowPackDetailScreen(
addressTag: String,
state: FollowPacksState,
cache: DesktopLocalCache,
iAccount: DesktopIAccount,
relayManager: RelayConnectionManager,
onBack: () -> Unit,
onNavigateToProfile: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val allPacks by state.allPacks.collectAsState()
@Suppress("UNUSED_VARIABLE")
val metadataVersion by cache.metadataVersion.collectAsState()
val pack: FollowListEvent? =
remember(addressTag, allPacks) {
allPacks.firstOrNull { FollowPackEditor.aTag(it) == addressTag }
}
if (pack == null) {
EmptyOrLoadingDetail(onBack = onBack)
return
}
// Prefetch metadata for members so avatars + names populate.
DisposableEffect(pack.id) {
val memberHexes = pack.followIdSet().toList().take(200)
val handle = relayManager.subscribeMetadataFor(memberHexes, cache)
onDispose { handle() }
}
val followedAuthors by iAccount.kind3FollowList.flow.collectAsState()
val followedSet = followedAuthors.authors
var showFollowAll by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
Column(modifier = modifier.fillMaxSize()) {
// Top bar with back button
Row(
modifier = Modifier.fillMaxWidth().padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack, modifier = Modifier.size(36.dp)) {
Icon(
MaterialSymbols.AutoMirrored.ArrowBack,
contentDescription = "Back",
modifier = Modifier.size(20.dp),
)
}
Spacer(Modifier.width(4.dp))
Text(
text = pack.title()?.ifBlank { null } ?: "Untitled pack",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding =
androidx.compose.foundation.layout
.PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item("hero") {
val fullyFollowed =
pack.followIds().isNotEmpty() &&
pack.followIds().all { it == iAccount.pubKey || it in followedSet }
PackHeroDetail(
pack = pack,
cache = cache,
onFollowAll = { showFollowAll = true },
onShare = { copyToClipboardLocal(FollowPackEditor.toShareUri(pack)) },
fullyFollowed = fullyFollowed,
)
}
item("members-header") {
Text(
text = "MEMBERS (${pack.follows().size})",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 16.dp, bottom = 4.dp),
)
}
items(items = pack.followIds(), key = { it }) { hex ->
MemberRow(
pubkeyHex = hex,
cache = cache,
isFollowed = hex in followedSet,
onOpenProfile = { onNavigateToProfile(hex) },
onFollow = {
scope.launch {
val user = cache.getOrCreateUser(hex)
val newCl = iAccount.kind3FollowList.follow(user)
cache.consume(newCl, null, wasVerified = false)
relayManager.broadcastToAll(newCl)
}
},
onUnfollow = {
scope.launch {
val user = cache.getOrCreateUser(hex)
val newCl = iAccount.kind3FollowList.unfollow(user) ?: return@launch
cache.consume(newCl, null, wasVerified = false)
relayManager.broadcastToAll(newCl)
}
},
)
}
}
}
if (showFollowAll) {
FollowAllConfirmDialog(
pack = pack,
iAccount = iAccount,
cache = cache,
relayManager = relayManager,
onDismiss = { showFollowAll = false },
)
}
}
@Composable
private fun PackHeroDetail(
pack: FollowListEvent,
cache: DesktopLocalCache,
onFollowAll: () -> Unit,
onShare: () -> Unit,
fullyFollowed: Boolean,
) {
val title = pack.title()?.ifBlank { null } ?: "Untitled pack"
val description = pack.description()?.ifBlank { null }
val image = pack.image()?.ifBlank { null }
val memberCount = pack.follows().size
Column(
modifier =
Modifier
.fillMaxWidth()
.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(16.dp),
).padding(16.dp),
) {
Box(
modifier =
Modifier
.fillMaxWidth()
.height(160.dp)
.clip(RoundedCornerShape(12.dp))
.background(
Color(
(title.hashCode() and 0x80FFFFFF.toInt()) or 0xFF608080.toInt(),
),
),
) {
if (image != null) {
AsyncImage(
model = image,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().height(160.dp),
)
}
}
Spacer(Modifier.height(16.dp))
Text(text = title, style = MaterialTheme.typography.headlineSmall)
if (description != null) {
Spacer(Modifier.height(6.dp))
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(12.dp))
FollowPackAvatarStack(
memberHexes = pack.followIds(),
cache = cache,
avatarSize = 36.dp,
maxVisible = 8,
)
Spacer(Modifier.height(8.dp))
Text(
text = "$memberCount ${if (memberCount == 1) "member" else "members"}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (fullyFollowed) {
OutlinedButton(onClick = onFollowAll) {
Icon(
symbol = MaterialSymbols.PersonAdd,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(6.dp))
Text("Unfollow all")
}
} else {
Button(onClick = onFollowAll, enabled = memberCount > 0) {
Icon(
symbol = MaterialSymbols.PersonAdd,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(6.dp))
Text("Follow all")
}
}
OutlinedButton(onClick = onShare) {
Icon(
symbol = MaterialSymbols.ContentCopy,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(6.dp))
Text("Share")
}
}
}
}
@Composable
private fun MemberRow(
pubkeyHex: HexKey,
cache: DesktopLocalCache,
isFollowed: Boolean,
onOpenProfile: () -> Unit,
onFollow: () -> Unit,
onUnfollow: () -> Unit,
) {
@Suppress("UNUSED_VARIABLE")
val metadataVersion by cache.metadataVersion.collectAsState()
val user = remember(pubkeyHex, metadataVersion) { cache.getUserIfExists(pubkeyHex) }
val displayName = user?.toBestDisplayName() ?: (pubkeyHex.take(12) + "…")
val avatarUrl = user?.profilePicture()
Row(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onOpenProfile)
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
UserAvatar(
userHex = pubkeyHex,
pictureUrl = avatarUrl,
size = 36.dp,
contentDescription = displayName,
)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = displayName,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val nip05 = user?.metadataOrNull()?.nip05()
if (!nip05.isNullOrBlank()) {
Text(
text = nip05,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (isFollowed) {
OutlinedButton(onClick = onUnfollow) {
Text("Unfollow")
}
} else {
Button(onClick = onFollow) {
Text("Follow")
}
}
}
}
@Composable
private fun EmptyOrLoadingDetail(onBack: () -> Unit) {
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier.fillMaxWidth().padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack, modifier = Modifier.size(36.dp)) {
Icon(
MaterialSymbols.AutoMirrored.ArrowBack,
contentDescription = "Back",
modifier = Modifier.size(20.dp),
)
}
}
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = "Pack not found in cache.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -0,0 +1,283 @@
/*
* 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.desktop.followpacks.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.followpacks.subscribeMetadataFor
import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
/**
* Horizontal row of up to 3 recent notes from authors in [memberHexes].
* Each card shows avatar + name + handle + content + first image (if any).
*/
@Composable
fun FromThePackFeed(
memberHexes: Set<HexKey>,
cache: DesktopLocalCache,
relayManager: RelayConnectionManager,
onNavigateToProfile: (String) -> Unit,
onNavigateToThread: (String) -> Unit,
modifier: Modifier = Modifier,
maxNotes: Int = 3,
) {
@Suppress("UNUSED_VARIABLE")
val metadataVersion by cache.metadataVersion.collectAsState()
DisposableEffect(memberHexes.hashCode()) {
if (memberHexes.isEmpty()) {
onDispose { }
} else {
val subId = "pack-notes-${memberHexes.hashCode()}"
val filter =
Filter(
kinds = listOf(TextNoteEvent.KIND),
authors = memberHexes.toList().take(50),
limit = 50,
)
relayManager.subscribe(
subId,
listOf(filter),
listener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
cache.consume(event, relay)
}
},
)
val metaHandle = relayManager.subscribeMetadataFor(memberHexes.toList().take(50), cache)
onDispose {
relayManager.unsubscribe(subId)
metaHandle()
}
}
}
val notes =
remember(memberHexes, metadataVersion) {
val out = mutableListOf<NotePreview>()
cache.notes.forEach { _, note ->
val ev = note.event
if (ev is TextNoteEvent && ev.pubKey in memberHexes) {
val author = cache.getUserIfExists(ev.pubKey)
out.add(
NotePreview(
id = ev.id,
pubKey = ev.pubKey,
createdAt = ev.createdAt,
content = ev.content,
imageUrl = firstImageUrl(ev.content),
authorName = author?.toBestDisplayName(),
authorNip05 = author?.metadataOrNull()?.nip05(),
authorAvatarUrl = author?.profilePicture(),
),
)
}
}
out.sortByDescending { it.createdAt }
out.take(maxNotes)
}
if (notes.isEmpty()) {
Box(
modifier = modifier.fillMaxWidth().padding(16.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = "Loading recent notes from this pack…",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
return
}
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
notes.forEach { note ->
NoteCardPretty(
note = note,
onOpenThread = { onNavigateToThread(note.id) },
onOpenProfile = { onNavigateToProfile(note.pubKey) },
modifier = Modifier.weight(1f),
)
}
// If fewer than maxNotes results, pad with empty Spacers so cards keep their proportional width.
repeat(maxNotes - notes.size) {
Spacer(modifier = Modifier.weight(1f))
}
}
}
private data class NotePreview(
val id: String,
val pubKey: HexKey,
val createdAt: Long,
val content: String,
val imageUrl: String?,
val authorName: String?,
val authorNip05: String?,
val authorAvatarUrl: String?,
)
@Composable
private fun NoteCardPretty(
note: NotePreview,
onOpenThread: () -> Unit,
onOpenProfile: () -> Unit,
modifier: Modifier = Modifier,
) {
val name = note.authorName ?: (note.pubKey.take(12) + "…")
val handle = note.authorNip05?.removePrefix("_@")
val relative = relativeTime(note.createdAt)
Column(
modifier =
modifier
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surface)
.clickable(onClick = onOpenThread)
.padding(12.dp),
) {
// Author row
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier.clickable(onClick = onOpenProfile),
) {
UserAvatar(
userHex = note.pubKey,
pictureUrl = note.authorAvatarUrl,
size = 36.dp,
contentDescription = name,
)
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = name,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (handle != null) {
Text(
text = "@$handle • $relative",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
} else {
Text(
text = relative,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Spacer(Modifier.height(8.dp))
Text(
text = note.content.trim(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = if (note.imageUrl != null) 3 else 6,
overflow = TextOverflow.Ellipsis,
)
if (note.imageUrl != null) {
Spacer(Modifier.height(10.dp))
AsyncImage(
model = note.imageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 120.dp, max = 200.dp)
.clip(RoundedCornerShape(8.dp)),
)
}
}
}
// --- helpers ---
private val IMAGE_URL_REGEX =
Regex(
"https?://\\S+\\.(jpg|jpeg|png|webp|gif|bmp)(\\?[^\\s]*)?",
RegexOption.IGNORE_CASE,
)
private fun firstImageUrl(content: String): String? = IMAGE_URL_REGEX.find(content)?.value
private fun relativeTime(createdAtSec: Long): String {
val now = System.currentTimeMillis() / 1000L
val delta = (now - createdAtSec).coerceAtLeast(0)
return when {
delta < 60 -> "now"
delta < 3600 -> "${delta / 60}m"
delta < 86_400 -> "${delta / 3600}h"
delta < 30L * 86_400 -> "${delta / 86_400}d"
delta < 365L * 86_400 -> "${delta / (30L * 86_400)}mo"
else -> "${delta / (365L * 86_400)}y"
}
}
@@ -0,0 +1,238 @@
/*
* 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.desktop.followpacks.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.followpacks.FollowPackEditor
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress as NAddressEntity
/**
* Inline rich card for `naddr` references to kind 39089 (Follow Pack) events
* embedded inside notes. Renders avatar stack + Follow-all CTA.
*
* If the addressable event isn't in the cache, subscribes for it and shows a
* skeleton until it arrives.
*/
@Composable
fun RenderFollowPackCard(
address: NAddressEntity,
cache: DesktopLocalCache,
iAccount: DesktopIAccount?,
relayManager: RelayConnectionManager,
onOpenPack: (String) -> Unit,
modifier: Modifier = Modifier,
) {
@Suppress("UNUSED_VARIABLE")
val packVersion by cache.followPackVersion.collectAsState()
val aTag = remember(address) { Address.assemble(address.kind, address.author, address.dTag) }
val pack: FollowListEvent? =
remember(packVersion, aTag) {
cache.snapshotFollowPacks().firstOrNull { FollowPackEditor.aTag(it) == aTag }
}
// If not in cache, kick off a one-shot subscription
if (pack == null) {
DisposableEffect(aTag) {
val subId = "naddr-fetch-${aTag.hashCode()}"
val filter =
Filter(
kinds = listOf(address.kind),
authors = listOf(address.author),
tags = mapOf("d" to listOf(address.dTag)),
limit = 1,
)
relayManager.subscribe(
subId,
listOf(filter),
listener =
object : com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener {
override fun onEvent(
event: com.vitorpamplona.quartz.nip01Core.core.Event,
isLive: Boolean,
relay: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
cache.consume(event, relay)
}
},
)
onDispose {
relayManager.unsubscribe(subId)
}
}
PackCardSkeleton(modifier = modifier)
return
}
if (pack.follows().isEmpty()) {
// Empty pack — render minimal state, no Follow-all CTA (gap C5)
Card(
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = pack.title()?.ifBlank { null } ?: "Untitled pack",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(4.dp))
Text(
text = "Empty pack",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
}
}
return
}
var showFollowAll by remember { mutableStateOf(false) }
Card(
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
shape = RoundedCornerShape(12.dp),
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier =
Modifier
.size(48.dp)
.clip(RoundedCornerShape(8.dp))
.background(
Color(
((pack.title() ?: pack.id).hashCode() and 0x80FFFFFF.toInt()) or 0xFF608080.toInt(),
),
),
)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = pack.title()?.ifBlank { null } ?: "Follow pack",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "${pack.follows().size} people",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.height(8.dp))
FollowPackAvatarStack(
memberHexes = pack.followIds(),
cache = cache,
avatarSize = 28.dp,
maxVisible = 6,
)
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { onOpenPack(aTag) }) { Text("Open") }
if (iAccount != null) {
TextButton(onClick = { showFollowAll = true }) {
Text("Follow all")
}
}
}
}
}
if (showFollowAll && iAccount != null) {
FollowAllConfirmDialog(
pack = pack,
iAccount = iAccount,
cache = cache,
relayManager = relayManager,
onDismiss = { showFollowAll = false },
)
}
}
@Composable
private fun PackCardSkeleton(modifier: Modifier = Modifier) {
Card(
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(modifier = Modifier.padding(12.dp)) {
Box(
modifier =
Modifier
.fillMaxWidth()
.height(16.dp)
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)),
)
Spacer(Modifier.height(8.dp))
Box(
modifier =
Modifier
.fillMaxWidth(0.6f)
.height(12.dp)
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)),
)
}
}
}
@@ -124,6 +124,8 @@ fun DeckColumnType.category(): ScreenCategory =
DeckColumnType.Search,
is DeckColumnType.Hashtag,
DeckColumnType.Bookmarks,
DeckColumnType.Discover,
DeckColumnType.FollowPacks,
-> ScreenCategory.DISCOVERY
DeckColumnType.MyProfile,
@@ -166,6 +168,8 @@ val LAUNCHABLE_SCREENS: List<DeckColumnType> =
DeckColumnType.Notifications,
DeckColumnType.Messages,
DeckColumnType.GlobalFeed,
DeckColumnType.Discover,
DeckColumnType.FollowPacks,
DeckColumnType.Reads,
DeckColumnType.Drafts,
DeckColumnType.Editor(),
@@ -131,4 +131,6 @@ fun DeckColumnType.icon(): MaterialSymbol =
is DeckColumnType.Thread -> MaterialSymbols.AutoMirrored.Article
is DeckColumnType.Hashtag -> MaterialSymbols.Tag
is DeckColumnType.CustomFeed -> MaterialSymbols.Tune
DeckColumnType.Discover -> MaterialSymbols.Explore
DeckColumnType.FollowPacks -> MaterialSymbols.Apps
}
@@ -227,6 +227,8 @@ fun DeckColumnContainer(
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) },
onNavigateToRelays = onNavigateToRelays,
onNavigateToPack = { navState.push(DesktopScreen.FollowPackDetail(it)) },
onNavigateToPackBrowseAll = { navState.push(DesktopScreen.FollowPackBrowseAll) },
)
// Overlay with slide animation
@@ -284,6 +286,7 @@ fun DeckColumnContainer(
relayManager = relayManager,
localCache = localCache,
account = account,
iAccount = iAccount,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
@@ -294,6 +297,7 @@ fun DeckColumnContainer(
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
onNavigateToPack = { navState.push(DesktopScreen.FollowPackDetail(it)) },
onBack = { navState.pop() },
)
}
@@ -327,6 +331,8 @@ internal fun RootContent(
onNavigateToEditor: (String?) -> Unit = {},
onNavigateToRelays: () -> Unit = {},
onOpenFeedsDrawer: () -> Unit = {},
onNavigateToPack: (String) -> Unit = {},
onNavigateToPackBrowseAll: () -> Unit = {},
) {
val scope = rememberCoroutineScope()
@@ -575,6 +581,37 @@ internal fun RootContent(
onZapFeedback = onZapFeedback,
)
}
DeckColumnType.Discover -> {
val followPacks = LocalFollowPacksState.current
if (followPacks != null) {
com.vitorpamplona.amethyst.desktop.followpacks.ui
.DiscoverScreen(
state = followPacks,
cache = localCache,
iAccount = iAccount,
relayManager = relayManager,
onOpenPack = { aTag -> onNavigateToPack(aTag) },
onOpenBrowseAll = { onNavigateToPackBrowseAll() },
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
onZapFeedback = onZapFeedback,
)
}
}
DeckColumnType.FollowPacks -> {
val followPacks = LocalFollowPacksState.current
if (followPacks != null) {
com.vitorpamplona.amethyst.desktop.followpacks.ui
.FollowPackBrowseAllScreen(
state = followPacks,
cache = localCache,
onOpenPack = { aTag -> onNavigateToPack(aTag) },
onBack = null,
)
}
}
}
}
@@ -584,6 +621,7 @@ internal fun OverlayContent(
relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache,
account: AccountState.LoggedIn,
iAccount: DesktopIAccount? = null,
nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
highlightStore: DesktopHighlightStore? = null,
@@ -594,6 +632,7 @@ internal fun OverlayContent(
onNavigateToProfile: (String) -> Unit,
onNavigateToThread: (String) -> Unit,
onNavigateToArticle: (String) -> Unit = {},
onNavigateToPack: (String) -> Unit = {},
onBack: () -> Unit,
) {
when (screen) {
@@ -656,6 +695,35 @@ internal fun OverlayContent(
)
}
is DesktopScreen.FollowPackDetail -> {
val followPacks = LocalFollowPacksState.current
if (followPacks != null && iAccount != null) {
com.vitorpamplona.amethyst.desktop.followpacks.ui
.FollowPackDetailScreen(
addressTag = screen.addressTag,
state = followPacks,
cache = localCache,
iAccount = iAccount,
relayManager = relayManager,
onBack = onBack,
onNavigateToProfile = onNavigateToProfile,
)
}
}
is DesktopScreen.FollowPackBrowseAll -> {
val followPacks = LocalFollowPacksState.current
if (followPacks != null) {
com.vitorpamplona.amethyst.desktop.followpacks.ui
.FollowPackBrowseAllScreen(
state = followPacks,
cache = localCache,
onOpenPack = { aTag -> onNavigateToPack(aTag) },
onBack = onBack,
)
}
}
else -> {
androidx.compose.material3.Text(
"Unsupported screen type",
@@ -77,6 +77,10 @@ sealed class DeckColumnType {
val feedEmoji: String = "",
) : DeckColumnType()
object Discover : DeckColumnType()
object FollowPacks : DeckColumnType()
fun title(): String =
when (this) {
HomeFeed -> "Home"
@@ -99,6 +103,8 @@ sealed class DeckColumnType {
is Thread -> "Thread"
is Hashtag -> "#$tag"
is CustomFeed -> feedName.ifEmpty { "Feed" }
Discover -> "Discover"
FollowPacks -> "Follow Packs"
}
fun typeKey(): String =
@@ -123,6 +129,8 @@ sealed class DeckColumnType {
is Thread -> "thread"
is Hashtag -> "hashtag"
is CustomFeed -> "custom_feed"
Discover -> "discover"
FollowPacks -> "follow_packs"
}
}
@@ -99,6 +99,7 @@ private data class NavItem(
private val NAV_ITEMS =
listOf(
NavItem(DeckColumnType.HomeFeed, "Home", MaterialSymbols.Home),
NavItem(DeckColumnType.Discover, "Discover", MaterialSymbols.Explore),
NavItem(DeckColumnType.Search, "Search", MaterialSymbols.Search),
NavItem(DeckColumnType.Messages, "Messages", MaterialSymbols.Mail),
NavItem(DeckColumnType.Wallet, "Wallet", MaterialSymbols.AccountBalanceWallet),
@@ -308,6 +308,8 @@ class DeckState(
"thread" -> param?.let { DeckColumnType.Thread(it) }
"hashtag" -> param?.let { DeckColumnType.Hashtag(it) }
"custom_feed" -> param?.let { DeckColumnType.CustomFeed(feedId = it) }
"discover" -> DeckColumnType.Discover
"follow_packs" -> DeckColumnType.FollowPacks
else -> null
}
}
@@ -95,3 +95,8 @@ val LocalRelayListMutator =
compositionLocalOf<com.vitorpamplona.amethyst.commons.relays.health.RelayListMutator?> {
null
}
val LocalFollowPacksState =
compositionLocalOf<com.vitorpamplona.amethyst.desktop.followpacks.FollowPacksState?> {
null
}
@@ -134,6 +134,8 @@ fun SinglePaneLayout(
onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) },
onNavigateToRelays = { singlePaneState.navigate(DeckColumnType.Relays) },
onOpenFeedsDrawer = onOpenFeedsDrawer,
onNavigateToPack = { navState.push(DesktopScreen.FollowPackDetail(it)) },
onNavigateToPackBrowseAll = { navState.push(DesktopScreen.FollowPackBrowseAll) },
)
AnimatedContent(
targetState = currentOverlay,
@@ -159,6 +161,7 @@ fun SinglePaneLayout(
relayManager = relayManager,
localCache = localCache,
account = account,
iAccount = iAccount,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
@@ -169,6 +172,7 @@ fun SinglePaneLayout(
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
onNavigateToPack = { navState.push(DesktopScreen.FollowPackDetail(it)) },
onBack = { navState.pop() },
)
}
@@ -439,6 +439,29 @@ private fun RenderBechSegment(
localCache: DesktopLocalCache?,
callbacks: RichTextCallbacks,
) {
// Try NAddress first — kind 39089 (Follow Pack) renders as a rich card.
val naddr =
remember(segment.segmentText) {
com.vitorpamplona.quartz.nip19Bech32.entities
.NAddress
.parse(segment.segmentText)
}
if (naddr != null && naddr.kind == com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent.KIND) {
val followPacks = com.vitorpamplona.amethyst.desktop.ui.deck.LocalFollowPacksState.current
val relayManager = com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayManager.current
if (localCache != null && relayManager != null && followPacks != null) {
com.vitorpamplona.amethyst.desktop.followpacks.ui
.RenderFollowPackCard(
address = naddr,
cache = localCache,
iAccount = null,
relayManager = relayManager,
onOpenPack = { /* no nav callback available in rich-text context */ },
)
return
}
}
val resolved =
remember(segment.segmentText, localCache) {
resolveBech32(segment.segmentText, localCache)
@@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.nip51Lists.remove
@@ -68,6 +69,8 @@ class FollowListEvent(
fun followIdSet() = tags.followIdSet()
fun hashtags(): List<String> = tags.mapNotNull(HashtagTag::parse)
companion object {
const val KIND = 39089