mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
Binary file not shown.
+26
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -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")
|
||||
|
||||
+38
-16
@@ -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()) {
|
||||
|
||||
Vendored
+47
@@ -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 -----
|
||||
|
||||
/**
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.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
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -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())
|
||||
}
|
||||
+199
@@ -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()
|
||||
}
|
||||
+59
@@ -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) }
|
||||
}
|
||||
+352
@@ -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)
|
||||
}
|
||||
}
|
||||
+156
@@ -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")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+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.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))
|
||||
}
|
||||
}
|
||||
+361
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+396
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+283
@@ -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"
|
||||
}
|
||||
}
|
||||
+238
@@ -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(),
|
||||
|
||||
+2
@@ -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
|
||||
}
|
||||
|
||||
+68
@@ -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",
|
||||
|
||||
+8
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -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
|
||||
}
|
||||
|
||||
+4
@@ -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() },
|
||||
)
|
||||
}
|
||||
|
||||
+23
@@ -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)
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user